From af9b7a7dd7c34dbf993975d1014cb4d7c74d3d76 Mon Sep 17 00:00:00 2001 From: "kubestellar-hive[bot]" Date: Tue, 22 Sep 2026 10:44:04 -0400 Subject: [PATCH] test: cover the CaseStudies render contract and its filter behaviour src/components/CaseStudies/index.js was never imported by any test, so it did not appear in the node --test coverage report at all. Its filter predicate and facet option lists are module-private, so the only way to reach them is to render the component. Add tests/case-studies.test.mjs, which imports the component through the existing tests/helpers-jsx.mjs tooling and drives useState/useMemo through React's dispatcher slot. It asserts the table rows against the real data/case-studies.json, the deduplicated and sorted select options, the case-insensitive and whitespace-trimming query filter, filter intersection, the empty state, the Clear filters gate and reset, the labelling and aria-live contract, and rel=noreferrer on every outbound link including the sync-status credit. Coverage of the component goes from absent to 100% lines / 100% functions / 94.59% branches. No production code changes. Signed-off-by: kubestellar-hive[bot] --- tests/case-studies.test.mjs | 385 ++++++++++++++++++++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 tests/case-studies.test.mjs diff --git a/tests/case-studies.test.mjs b/tests/case-studies.test.mjs new file mode 100644 index 00000000..f647b720 --- /dev/null +++ b/tests/case-studies.test.mjs @@ -0,0 +1,385 @@ +// Render contract for src/components/CaseStudies/index.js. +// +// The component bakes `@site/data/case-studies.json` in at module scope and +// keeps every filter in local state, so neither the filtering predicate nor +// the option lists are reachable as exports. Driving the component through +// React's hook dispatcher is therefore the only way to cover them, and it has +// the side benefit of asserting the rendered table against the real mirrored +// data rather than a fixture that can drift from it. + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import React from 'react'; + +import { importSource } from './helpers-jsx.mjs'; +import { + findAllByType, + findByType, + textOf, + walkElements, +} from './tools/react-element-tree.mjs'; + +const CaseStudies = (await importSource('src/components/CaseStudies/index.js')) + .default; +const data = (await importSource('data/case-studies.json')).default; + +const STUDIES = data.caseStudies ?? []; + +// React routes useState/useMemo through a per-render "dispatcher" slot. +// Swapping that slot lets a plain Node test render a function component with +// no renderer and no DOM; the slot is restored as soon as the render returns, +// so it never leaks into another test in this process. +const Internals = + React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE; + +function sameDeps(a, b) { + if (a === undefined || b === undefined) return false; + return a.length === b.length && a.every((dep, i) => Object.is(dep, b[i])); +} + +/** + * Renders a component that uses only useState and useMemo, re-rendering + * synchronously whenever a state setter is called. + * + * @param {Function} Component component under test + * @returns {{ tree: any }} live view of the most recent element tree + */ +function render(Component) { + const states = []; + const memos = []; + const view = { tree: null }; + + function run() { + let stateSlot = 0; + let memoSlot = 0; + const previousDispatcher = Internals.H; + Internals.H = { + useState(initial) { + const slot = stateSlot++; + if (slot === states.length) { + states.push(typeof initial === 'function' ? initial() : initial); + } + const setState = (next) => { + const value = typeof next === 'function' ? next(states[slot]) : next; + if (Object.is(value, states[slot])) return; + states[slot] = value; + run(); + }; + return [states[slot], setState]; + }, + useMemo(create, deps) { + const slot = memoSlot++; + const previous = memos[slot]; + if (previous && sameDeps(previous.deps, deps)) return previous.value; + const value = create(); + memos[slot] = { deps, value }; + return value; + }, + }; + try { + view.tree = Component(); + } finally { + Internals.H = previousDispatcher; + } + } + + run(); + return view; +} + +function elementById(tree, id) { + for (const element of walkElements(tree)) { + if (element.props?.id === id) return element; + } + return undefined; +} + +/** Fires an onChange handler the way a DOM change event would. */ +function change(element, value) { + element.props.onChange({ target: { value } }); +} + +/** Every option value a