Batch of reported bugs and enhancements. UI - Pages no longer inherit the previous page's scroll position (DR-156, UR-072). The shell keeps its scrollers alive across navigation by design, so the element never remounts and its scrollTop survived the route change; SvelteKit restores window scroll, which this app never uses. ScrollMemory records the offset per route and per container: forward moves reset to the top, Back restores where the route was left. - Season header stacks on narrow screens, and the title span gets min-w-0 so it actually truncates instead of overflowing under the action buttons. - Favourites gets a labelled tile at the head of the library grid rather than only an unlabelled heart icon in the header. Playback - Full-screen video on Android hides the system bars (DR-157, UR-066). requestFullscreen() cannot touch the Activity window from inside a WebView, so the control did nothing visible while the bars stayed painted over the video. ImmersiveModeBridge hides them, restored on exit, Escape and teardown. - Background-audio handoff stops leaking its relative timeline (DR-159). background_audio_base was a display-only correction applied in two places while progress reports to Jellyfin, the frontend and media3's own seeks all worked in the relative timeline treating it as absolute — each crossing losing exactly `base` seconds. The conversion now happens once, in the position tick, and inbound seeks resolve through seek_absolute, which re-opens the stream at the requested position because the handoff transcode cannot seek. - Picture-in-picture works on the path that actually plays video (DR-160). canEnterPip demanded a native ExoPlayer surface, but that path is behind a flag defaulting to off, so PiP could never engage. It now accepts the WebView <video> too, keeping the WebView visible and routing play/pause to the element. - Native video is now the default so PiP has a real surface (DR-161). The scrub-regression tests pinned the flag-off path implicitly; they now mock it off explicitly. The native scrub/seek path is not covered by the suite and needs device verification. Watched state - Watched toggle on the episode row, season header, series and movie hero, and the Episode Focus View (DR-158, UR-073). Both backend halves already existed with no caller. storage_set_watched covers a container's episodes so the toggle is honest offline, and QueuedOp::MarkUnplayed gives the sync queue the missing direction. Release - Fix the Android versionCode floor (set-version.sh). v0.5.2 shipped code 5002 under an earlier minor*1000 scheme, but the current minor*100 formula yields 1502 for that version and 1503 for 0.5.3 — so every 0.5.x release built from it was an un-installable downgrade for anyone already on v0.5.2. Widened to 10000 + major*1000000 + minor*1000 + patch (0.5.3 -> 15003). - Bump to 0.5.3.
183 lines
6.6 KiB
TypeScript
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(73);
|
|
expect(defined.IR).toBe(32);
|
|
expect(defined.DR).toBe(156);
|
|
expect(defined.JA).toBe(35);
|
|
expect(defined.total).toBe(296);
|
|
});
|
|
});
|