Files
jellytau/scripts/extract-traces.test.ts
T
dtourolle caebf2d139 fix(android): keep the display awake while video plays
Android counts its display timeout from the last user input, and watching
something is exactly the case where there is none — so the screen dimmed and
slept mid-playback unless the user kept tapping it.

Nothing held it. FLAG_KEEP_SCREEN_ON appeared nowhere in the app, and neither
renderer supplies a hold for free: ExoPlayer's setWakeMode is a CPU/wifi wake
lock that says nothing about the display, and it draws into the TextureView we
own (DR-192) rather than media3's PlayerView, which is the widget that would
otherwise set keepScreenOn itself; the webview <video> path is no better,
because the display wake lock Chrome takes for video lives in the browser layer
and not in an embedded WebView.

ScreenWakeManager toggles FLAG_KEEP_SCREEN_ON on the Activity window — window
scoped, so it stops applying the moment the app is not visible and cannot
outlive a crash the way an acquired PowerManager.WakeLock can, and it needs no
permission. The two rendering paths are independent holders OR-ed in the pure
ScreenWakeState: the native path follows onIsPlayingChanged plus surface
teardown, so the hold tracks what ExoPlayer reports rather than what the UI
intends, and the webview path reuses the setHtml5VideoState report the frontend
already sends for PiP. Audio is deliberately not a holder — screen-off music is
the point of that path.

Also the repo's first Kotlin JVM unit tests: ScreenWakeState is framework-free,
so the decision is testable off-device with

    ./gradlew :app:testUniversalDebugUnitTest

(note the variant — plain testDebugUnitTest is ambiguous here). sync-android
-sources.sh mirrors src/test into the gen tree alongside the main sources.

TRACES: UR-003, UR-004 | DR-202 | UT-199
2026-08-18 14:56:08 +02:00

280 lines
10 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 * as fs from "fs";
import * as path from "path";
import {
countDefinedRequirements,
computeCoverage,
findDanglingIds,
MIN_COVERAGE_PERCENT,
} from "./extract-traces";
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
const HERE = path.dirname(new URL(import.meta.url).pathname);
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);
});
it("collects UT/IT rows separately, out of the coverage denominator", () => {
// §4 defines the test taxonomy. Those rows must be known (so a TRACES
// comment may name them) without ever moving the coverage ratio.
const md = `
| UR-001 | A | High | Done |
| UT-001 | Player state transitions | DR-001 | Pending |
| IT-004 | Playback end-to-end | DR-002 | Pending |
`;
const defined = countDefinedRequirements(md);
expect(defined.total).toBe(1);
expect(defined.ids.has("UT-001")).toBe(false);
expect(defined.testIds.has("UT-001")).toBe(true);
expect(defined.testIds.has("IT-004")).toBe(true);
});
});
describe("findDanglingIds", () => {
const defined = {
UR: 1,
IR: 0,
DR: 1,
JA: 0,
total: 2,
ids: new Set(["UR-001", "DR-001"]),
testIds: new Set(["UT-001"]),
};
it("flags a requirement ID that requirements.md does not define", () => {
expect(findDanglingIds(["UR-001", "DR-189"], defined)).toEqual(["DR-189"]);
});
it("flags an undefined UT/IT id, which the coverage orphan list cannot", () => {
// The gap this closes: computeCoverage deliberately ignores UT/IT, so
// UT-188 sat in three source files, defined nowhere, entirely unreported.
expect(computeCoverage(["UT-188"], defined).orphaned).toEqual([]);
expect(findDanglingIds(["UT-188"], defined)).toEqual(["UT-188"]);
});
it("accepts every ID that is defined, requirement or test", () => {
expect(findDanglingIds(["UR-001", "DR-001", "UT-001"], defined)).toEqual([]);
});
it("deduplicates and sorts, so one typo is reported once", () => {
expect(
findDanglingIds(["DR-189", "DR-189", "UR-999", "DR-189"], defined)
).toEqual(["DR-189", "UR-999"]);
});
it("ignores IDs whose prefix is not a known trace type", () => {
// e.g. an unrelated "AB-123" caught by the loose ID regex.
expect(findDanglingIds(["AB-123"], defined)).toEqual([]);
});
});
describe("coverage threshold", () => {
it("matches MIN_THRESHOLD in the Gitea traceability workflow", () => {
// Two files must agree on the gate: the script (local `traces:coverage`)
// and the workflow. Drift means the local gate and CI disagree about what
// passes, which is how the 50%-while-actually-86% slack went unnoticed.
const workflow = fs.readFileSync(
path.resolve(HERE, "../.gitea/workflows/traceability-check.yml"),
"utf-8"
);
const match = workflow.match(/^\s*MIN_THRESHOLD=(\d+)\s*$/m);
expect(match).not.toBeNull();
expect(Number(match![1])).toBe(MIN_COVERAGE_PERCENT);
});
it("is a ratchet: never lower it to make a red build pass", () => {
// Sanity bound. If coverage genuinely climbs, raise both numbers together.
expect(MIN_COVERAGE_PERCENT).toBeGreaterThanOrEqual(82);
expect(MIN_COVERAGE_PERCENT).toBeLessThanOrEqual(100);
});
});
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"]),
testIds: new Set<string>(),
};
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>(),
testIds: 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 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);
// 192 = 187 + four requirements added independently on four audit branches,
// plus DR-201 (lockscreen skip resolution). Originally 191 = 187 + four
// that landed together: DR-189 (control-bar auto-hide), DR-198 (asset
// scope/CSP), DR-199 (webview mixed-content) and DR-200 (the
// POST_NOTIFICATIONS media-session exemption; renumbered from 198 on
// merge, where it collided). Each branch bumped for its own — merged,
// they sum. Resolve this by summing, never by taking one side. 193 adds
// DR-202 (video keeps the display awake).
expect(defined.DR).toBe(193);
expect(defined.JA).toBe(36);
expect(defined.total).toBe(336);
});
});