| 1 | import { readFileSync } from "node:fs"; |
| 2 | import { join } from "node:path"; |
| 3 | import { describe, expect, it } from "vitest"; |
| 4 | import { motionId, previewAssetPath, readCatalogue, searchEntries } from "./catalogue"; |
| 5 | import { discoveryText } from "./learning"; |
| 6 | |
| 7 | const catalogue = readCatalogue(); |
| 8 | const publicRoot = join(process.cwd(), "public", "ratatui"); |
| 9 | |
| 10 | describe("Ratatui explorer", () => { |
| 11 | it("finds the base composer by its public API alongside its responsive variants", () => { |
| 12 | const found = searchEntries(catalogue.entries, "NativeComposer"); |
| 13 | expect(found.map((entry) => entry.name)).toContain("native-composer"); |
| 14 | expect(found.map((entry) => entry.name)).toContain("native-composer-narrow"); |
| 15 | expect(searchEntries(catalogue.entries, "native composer narrow").map((entry) => entry.name)) |
| 16 | .toContain("native-composer-narrow"); |
| 17 | }); |
| 18 | |
| 19 | it("combines search and collection selection and leaves an empty search recoverable", () => { |
| 20 | expect(searchEntries(catalogue.entries, "fish", "habitat").map((entry) => entry.name)).toContain("fish-school"); |
| 21 | expect(searchEntries(catalogue.entries, "fish", "input")).toHaveLength(0); |
| 22 | expect(searchEntries(catalogue.entries, "" )).toHaveLength(catalogue.entries.length); |
| 23 | }); |
| 24 | |
| 25 | it("finds studio scenes by their displayed composed APIs instead of private fixture inputs", () => { |
| 26 | const studio = catalogue.entries.filter((entry) => entry.family === "studio"); |
| 27 | const shells = searchEntries(studio, "TerminalShell").map((entry) => entry.name); |
| 28 | expect(shells).toHaveLength(3); |
| 29 | expect(shells).toEqual(expect.arrayContaining(["showcase-work", "showcase-decision", "showcase-narrow"])); |
| 30 | expect(searchEntries(studio, "NativeComposer").map((entry) => entry.name)) |
| 31 | .toEqual(expect.arrayContaining(["showcase-work", "showcase-narrow"])); |
| 32 | expect(searchEntries(studio, "ApprovalCard").map((entry) => entry.name)).toEqual(["showcase-decision"]); |
| 33 | expect(searchEntries(studio, "Inputs")).toHaveLength(0); |
| 34 | for (const name of ["showcase-work", "showcase-decision", "showcase-narrow"]) { |
| 35 | const text = discoveryText(studio.find((entry) => entry.name === name)!); |
| 36 | expect(text).toContain("terminalshell"); |
| 37 | expect(text).not.toContain("inputs"); |
| 38 | } |
| 39 | }); |
| 40 | |
| 41 | it("keeps the hub hero entry available with its wide and narrow renders", () => { |
| 42 | const hero = catalogue.entries.find((entry) => entry.name === "showcase-work"); |
| 43 | expect(hero).toBeDefined(); |
| 44 | const data = JSON.parse(readFileSync(join(publicRoot, hero!.previewPath), "utf8")); |
| 45 | expect(data.previews["dark-truecolor"]["native"]).toContain("viewBox="); |
| 46 | expect(data.previews["dark-truecolor"]["40"]).toContain('viewBox="0 0 400 '); |
| 47 | }); |
| 48 | |
| 49 | it("rejects an asset path outside the generated component directory", () => { |
| 50 | const entry = catalogue.entries[0]; |
| 51 | expect(previewAssetPath(entry)).toBe(`/ratatui/entries/${entry.name}.json`); |
| 52 | expect(() => previewAssetPath({ ...entry, previewPath: "entries/../../secret.json" })).toThrow(); |
| 53 | }); |
| 54 | |
| 55 | it("provides every advertised profile and actual column width for every component", () => { |
| 56 | const names = new Set(catalogue.entries.map((entry) => entry.name)); |
| 57 | expect(names.size).toBe(catalogue.entries.length); |
| 58 | expect(catalogue.profiles).toHaveLength(9); |
| 59 | expect(catalogue.sizes.map((size) => size.id)).toEqual(["native", "40", "80", "120"]); |
| 60 | let buffers = 0; |
| 61 | for (const entry of catalogue.entries) { |
| 62 | expect(entry.api.length, entry.name).toBeGreaterThan(0); |
| 63 | expect(entry.source.url).toContain(`/blob/${catalogue.source.revision}/src/gallery/`); |
| 64 | const data = JSON.parse(readFileSync(join(publicRoot, entry.previewPath), "utf8")); |
| 65 | expect(data.name).toBe(entry.name); |
| 66 | expect(data.fixture.standalone).toBe(false); |
| 67 | expect(data.fixture.code.length).toBeGreaterThan(0); |
| 68 | for (const profile of catalogue.profiles) for (const size of catalogue.sizes) { |
| 69 | const svg = data.previews[profile.id][size.id]; |
| 70 | const width = size.id === "native" ? entry.width : Number(size.id); |
| 71 | expect(svg).toContain(`viewBox="0 0 ${width * 10} ${entry.height * 20}"`); |
| 72 | expect(svg).not.toMatch(/<script|foreignObject|https?:\/\/[^\s"']+\.(?:png|js|woff)/i); |
| 73 | buffers += 1; |
| 74 | } |
| 75 | } |
| 76 | expect(buffers).toBe(catalogue.entries.length * 9 * 4); |
| 77 | expect(catalogue.families.reduce((sum, family) => sum + family.count, 0)).toBe(catalogue.entries.length); |
| 78 | }); |
| 79 | |
| 80 | it("loads native motion with its real cadence and preserves the static accessibility profiles", () => { |
| 81 | for (const entry of catalogue.entries) { |
| 82 | const id = motionId(entry, "dark-truecolor"); |
| 83 | if (!id) continue; |
| 84 | const data = JSON.parse(readFileSync(join(publicRoot, "motion", `${id}.json`), "utf8")); |
| 85 | expect(data.frames.length).toBeGreaterThan(1); |
| 86 | expect(data.frameMs).toBeGreaterThanOrEqual(20); |
| 87 | expect(data.source).toMatch(/^https:\/\/github\.com\/codewhale-hq\/codewhale-ratatui\/blob\/[a-f0-9]{40}\//); |
| 88 | } |
| 89 | expect(motionId(catalogue.entries.find((entry) => entry.name === "showcase-work")!, "light-truecolor")).toBe("studio-light"); |
| 90 | expect(catalogue.profiles.map((profile) => profile.id)).toEqual(expect.arrayContaining(["ascii", "no-color", "ansi-16"])); |
| 91 | }); |
| 92 | }); |
| 93 |