Typing "theme" put the channel "The Meltzone Podcast" (matched only through its handle `@themeltzone`) above every "Theme: …" row, and Enter opened the channel — the palette listed matches in pure group order and always highlighted the first one. - CommandPalette ranks each word: label starts with it > elsewhere in the label > only the hint, keywords or group name; an item ranks by its worst word, then the sum. The group order stays fixed, rows are ranked inside each group, and the best row overall starts highlighted. The highlight is held by id and follows the best match until the arrows or the pointer pick a row, so a channel list landing mid-typing no longer shifts it. - A11y: result groups are `role="group"` named by their heading; `aria-expanded` is false with nothing to choose; the listbox stays mounted (hidden) so `aria-controls` never names a missing element, and "Nothing matches" sits outside it; a polite status announces the count once the typing pauses. - `Column`/`FilterMap`/`filterRows`/`isActive` move from `ui/tableHeader` to `lib/tableColumns`, and the player's auto/loop modes from `playerModalControls` to `lib/playerModes` — three lib helpers imported the column contract from a ui component, and `playerModalHooks` its types from a feature component. - eslint: `lib/` may not import from `components/` at any depth, type-only and re-exports included; the boundary contract test proves it fires. CLAUDE.md and the ui README say so. - keymap: `dispatch`'s docblock sat stacked on `matchesShortcut`'s; moved onto `dispatch`.
97 lines
4.5 KiB
TypeScript
97 lines
4.5 KiB
TypeScript
import { fileURLToPath } from "node:url";
|
|
|
|
import { ESLint } from "eslint";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
// E10 S1 declared `components/ui/` a platform layer and put the rule that makes it one — no feature
|
|
// components, no domain API slices — into eslint.config.js rather than prose. This asserts the rule
|
|
// actually FIRES, because a `no-restricted-imports` pattern that matches nothing is indistinguishable
|
|
// from a clean codebase: the first cut wrote its regexes in plain "..." literals, where `\.` collapses
|
|
// to the any-char `.`, and it silently let through both `../../lib/api` (the barrel every feature
|
|
// component imports) and `../../components/Feed` (the same file `../Feed` names). Eight green lanes
|
|
// said nothing (E10 review round 1). A rule stated in a comment must also be enforced in code — and a
|
|
// rule meant to enforce something must itself be tested.
|
|
const BOUNDARY = "no-restricted-imports";
|
|
|
|
// Linted as if it were this file, so eslint.config.js's `files: ["src/components/ui/**"]` block applies.
|
|
const AS_UI = "src/components/ui/__boundary_probe.tsx";
|
|
|
|
// fileURLToPath, not URL.pathname: on Windows the latter yields "/D:/…", which eslint cannot resolve
|
|
// a config from. Anchored to this file rather than process.cwd() so the test holds wherever it is run.
|
|
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
|
|
|
|
async function restrictedIn(source: string, filePath: string): Promise<string[]> {
|
|
const eslint = new ESLint({ cwd: FRONTEND });
|
|
const [result] = await eslint.lintText(source, { filePath });
|
|
return (result?.messages ?? []).filter((m) => m.ruleId === BOUNDARY).map((m) => m.message);
|
|
}
|
|
|
|
describe("components/ui/ boundary rule", () => {
|
|
it("refuses a feature component by BOTH of its spellings", async () => {
|
|
const found = await restrictedIn(
|
|
`import A from "../Sidebar";\nimport B from "../../components/Feed";\nexport default [A, B];\n`,
|
|
AS_UI,
|
|
);
|
|
expect(found).toHaveLength(2);
|
|
expect(found.every((m) => m.includes("may not import a feature component"))).toBe(true);
|
|
});
|
|
|
|
it("refuses a domain API slice AND the barrel that re-exports every slice", async () => {
|
|
const found = await restrictedIn(
|
|
`import { api } from "../../lib/api";\nimport * as p from "../../lib/api/playlists";\nexport default [api, p];\n`,
|
|
AS_UI,
|
|
);
|
|
expect(found).toHaveLength(2);
|
|
expect(found.every((m) => m.includes("may not call a domain API slice"))).toBe(true);
|
|
});
|
|
|
|
it("allows what the layer is FOR — sibling primitives and generic lib helpers", async () => {
|
|
const found = await restrictedIn(
|
|
`import B from "./Button";\nimport M from "../../components/ui/Modal";\nimport { qk } from "../../lib/queryKeys";\nexport default [B, M, qk];\n`,
|
|
AS_UI,
|
|
);
|
|
expect(found).toEqual([]);
|
|
});
|
|
|
|
it("binds only inside ui/ — a feature component may still import its neighbours", async () => {
|
|
const found = await restrictedIn(
|
|
`import { api } from "../lib/api";\nimport S from "./Sidebar";\nexport default [api, S];\n`,
|
|
"src/components/__boundary_probe.tsx",
|
|
);
|
|
expect(found).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// E11 S3: one layer down, `lib/` may not import from `components/` at all. The type-only and the
|
|
// re-export spellings are in here on purpose — both are how a lib helper most easily leans on a
|
|
// component (`lib/sortOptions` imported `Column` from `ui/tableHeader` as a type).
|
|
describe("lib/ boundary rule", () => {
|
|
const AS_LIB = "src/lib/__boundary_probe.ts";
|
|
|
|
it("refuses components/ by every spelling: value, type-only, re-export, and from a nested lib dir", async () => {
|
|
const source =
|
|
`import F from "../components/Feed";\n` +
|
|
`import type { Column } from "../components/ui/tableHeader";\n` +
|
|
`export type { FilterMap } from "../components/ui/tableHeader";\n` +
|
|
`export default F;\n`;
|
|
const found = await restrictedIn(source, AS_LIB);
|
|
const nested = await restrictedIn(
|
|
`import M from "../../components/ui/Modal";\nexport default M;\n`,
|
|
"src/lib/api/__boundary_probe.ts",
|
|
);
|
|
expect(found).toHaveLength(3);
|
|
expect(nested).toHaveLength(1);
|
|
expect([...found, ...nested].every((m) => m.includes("may not import from components/"))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("allows lib's own modules and the i18n setup", async () => {
|
|
const found = await restrictedIn(
|
|
`import { qk } from "./queryKeys";\nimport { api } from "./api";\nimport i18n from "../i18n";\nexport default [qk, api, i18n];\n`,
|
|
AS_LIB,
|
|
);
|
|
expect(found).toEqual([]);
|
|
});
|
|
});
|