Files
jellytau/scripts/extract-traces.test.ts
T
dtourolle 0861523015 feat(library,home): lay libraries out as a mosaic, with favourites per category
The library overview and the home shortcut strip showed artwork of three
different shapes — square music covers, 16:9 library backdrops, 2:3 posters —
in grids that pick one box and crop everything to it. The home strip said so
in a comment: it forced `aspect="video"` on music libraries so the row would
line up, which lined it up by cutting the covers down.

Both surfaces are now justified mosaics: rows share one height and each tile is
as wide as its own artwork. `layoutMosaic` is a pure module — it packs tiles
until the height needed to fill the container drops to the target, justifies the
row by absorbing the rounding remainder into its widest tile, and deliberately
leaves the last row unstretched so one leftover tile does not inflate into a
banner. The component supplies only what the DOM knows: the measured container
width, and the artwork's *decoded* aspect ratio (via a new `onNaturalSize` on
CachedImage), committed in one debounced batch so the grid does not reshuffle
once per image as artwork lands.

Favourites gain a tile per category beside the library it belongs to, alongside
the existing cross-library entry. Which collection type maps to which category
is Jellyfin vocabulary, so it is derived in Rust — `SearchScope::for_collection_type`,
stamped onto every `Library` by a new constructor and carried over as an optional
`favoritesScope`. Deriving it in Svelte would have rebuilt the exact leak
`SearchScope::item_types` was extracted to close. A category shows one tile
however many libraries share it, and a library kind favourites do not carve up
(Live TV, channels, books) gets none.

Also corrects the requirements-count test, which the UR-074 commit left one
behind.

Spec: docs/specs/library-mosaic.md
TRACES: UR-075, UR-067 | DR-163, DR-164 | UT-158..UT-162
2026-08-15 23:57:09 +02:00

183 lines
6.6 KiB
TypeScript

/**
* Tests for the traceability coverage computation.
*
* These run over fixture strings rather than the live docs/requirements.md, so
* their meaning does not drift as requirements are added.
*
* Background: the CI gate divided traced-requirement counts by hardcoded
* denominators (UR/39, IR/24, DR/48, JA/3, total 114) that had fallen out of
* date, reporting 158% coverage and making the 50% threshold unreachable. These
* tests pin the parsing and arithmetic that replace those literals.
*
* @req-test: UT-089 - Requirement definitions parsed from requirements.md
* @req-test: UT-090 - Coverage is the intersection of traced and defined IDs
*/
import { describe, it, expect } from "vitest";
import { countDefinedRequirements, computeCoverage } from "./extract-traces";
describe("countDefinedRequirements", () => {
it("counts a well-formed table row as a defined requirement", () => {
const md = `
| ID | Requirement | Priority | Status |
|----|-------------|----------|--------|
| UR-001 | Run the app on multiple platforms | High | In Progress |
| UR-002 | Access media when online or offline | High | Done |
`;
const defined = countDefinedRequirements(md);
expect(defined.UR).toBe(2);
expect(defined.DR).toBe(0);
});
it("does not count IDs that appear only in the Traces To column", () => {
// The bug this rule avoids: a naive grep for /DR-\d{3}/ over the whole file
// counts DR-001 here as "defined", inflating the denominator with IDs that
// are merely referenced.
const md = `
| DR-001 | Player state machine | Player | UR-005 | Done |
| DR-002 | MediaItem struct | Player | UR-003, UR-004 | Done |
`;
const defined = countDefinedRequirements(md);
expect(defined.DR).toBe(2);
// UR-005/UR-003/UR-004 are referenced, never defined here.
expect(defined.UR).toBe(0);
});
it("does not count IDs mentioned in prose", () => {
const md = `
Some prose explaining that UR-005 relates to DR-001 and JA-002.
| UR-005 | Control media playback | High | Done |
`;
const defined = countDefinedRequirements(md);
expect(defined.UR).toBe(1);
expect(defined.DR).toBe(0);
expect(defined.JA).toBe(0);
});
it("deduplicates an ID listed in both the spec table and the traceability matrix", () => {
// requirements.md lists every UR twice: once in §1 (definition) and again in
// §3 (traceability matrix), both as a leading table cell. Counting rows
// instead of unique IDs double-counts the UR denominator (121 vs 61).
const md = `
| UR-005 | Control media playback | High | Done |
| UR-006 | Browse the library | High | Done |
### Traceability Matrix
| UR-005 | - | DR-001, DR-005, DR-009 |
| UR-006 | - | DR-012 |
`;
const defined = countDefinedRequirements(md);
expect(defined.UR).toBe(2);
});
it("collects the defined ID set, not just counts", () => {
const md = `
| UR-001 | A | High | Done |
| DR-050 | B | Player | UR-001 | Done |
`;
const defined = countDefinedRequirements(md);
expect(defined.ids.has("UR-001")).toBe(true);
expect(defined.ids.has("DR-050")).toBe(true);
expect(defined.ids.has("UR-999")).toBe(false);
});
});
describe("computeCoverage", () => {
const defined = {
UR: 2,
IR: 0,
DR: 2,
JA: 0,
total: 4,
ids: new Set(["UR-001", "UR-002", "DR-001", "DR-002"]),
};
it("computes coverage as traced ∩ defined over defined", () => {
const traced = ["UR-001", "DR-001"];
const cov = computeCoverage(traced, defined);
expect(cov.covered).toBe(2);
expect(cov.total).toBe(4);
expect(cov.percent).toBe(50);
});
it("does not let a traced-but-undefined ID inflate the numerator", () => {
// This is how a ratio exceeds 100%: a TRACES comment naming a typo'd or
// deleted requirement counted as covered.
const traced = ["UR-001", "DR-001", "DR-097"];
const cov = computeCoverage(traced, defined);
expect(cov.covered).toBe(2);
expect(cov.percent).toBe(50);
});
it("reports traced-but-undefined IDs as orphaned so they get fixed", () => {
const traced = ["UR-001", "DR-097", "JA-404"];
const cov = computeCoverage(traced, defined);
expect(cov.orphaned).toEqual(["DR-097", "JA-404"]);
});
it("has no orphans when every traced ID is defined", () => {
const cov = computeCoverage(["UR-001", "UR-002"], defined);
expect(cov.orphaned).toEqual([]);
});
it("ignores UT/IT test IDs entirely — they are a separate taxonomy", () => {
// UT/IT are defined in §4 of requirements.md, not among the four
// requirement types. Treating them as orphans buries real typos in ~60
// lines of noise, and counting them would corrupt the ratio.
const cov = computeCoverage(["UR-001", "UT-088", "IT-017"], defined);
expect(cov.orphaned).toEqual([]);
expect(cov.covered).toBe(1);
});
it("reports 0% rather than dividing by zero for an empty trace set", () => {
const cov = computeCoverage([], defined);
expect(cov.covered).toBe(0);
expect(cov.percent).toBe(0);
});
it("reports 0% rather than NaN when nothing is defined", () => {
const empty = { UR: 0, IR: 0, DR: 0, JA: 0, total: 0, ids: new Set<string>() };
const cov = computeCoverage([], empty);
expect(cov.percent).toBe(0);
expect(Number.isNaN(cov.percent)).toBe(false);
});
it("reports exactly 100% when all defined requirements are traced, never above", () => {
const traced = ["UR-001", "UR-002", "DR-001", "DR-002"];
const cov = computeCoverage(traced, defined);
expect(cov.percent).toBe(100);
});
it("ignores duplicate traced IDs", () => {
const traced = ["UR-001", "UR-001", "UR-001"];
const cov = computeCoverage(traced, defined);
expect(cov.covered).toBe(1);
});
});
describe("live requirements.md", () => {
it("parses the real file to the counts the CI gate must use", () => {
// Guards the specific regression: CI hardcoded UR/39, IR/24, DR/48, JA/3
// (total 114) while the real file had grown to 211. Update these numbers
// deliberately when requirements are added — that edit is the signal the
// denominator is live rather than frozen.
const fs = require("fs");
const path = require("path");
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
const here = path.dirname(new URL(import.meta.url).pathname);
const md = fs.readFileSync(
path.resolve(here, "../docs/requirements.md"),
"utf-8"
);
const defined = countDefinedRequirements(md);
expect(defined.UR).toBe(75);
expect(defined.IR).toBe(32);
expect(defined.DR).toBe(159);
expect(defined.JA).toBe(35);
expect(defined.total).toBe(301);
});
});