Two ways round 6's own fixes did not bound what their comments said they bounded. The reclaim budget's window anchor was written only where the window RESET, so it marked the window's start rather than the last loss — a tumbling window, not the rolling one the constant is named for. Any loss cadence slower than half the window satisfied the reset on every second loss, so the budget never reached its bound: measured over 200 loss cycles, 61/70/90/119s apart all gave 200 reclaims and never an error, while 5s and 30s apart correctly gave two and then the honest answer. The failing band is exactly the case the ERROR handler names — a transcode that falls behind realtime — and both committed tests spaced their losses 0s and 10min apart, i.e. outside it. The anchor now moves with every loss. And `finish`'s refusal handler lowered the play-through latch, which is the only gate on the 10s interval, the page-hide beacon and `settle`. All three post `absRef`, which on the play-to-the-end path IS the end of the media, and the backend reads a position within `_FINISH_MARGIN_S` of the duration as a finish — so within ten seconds of the toast saying the mark had failed, the item was marked watched, its position cleared and a scrobble sent to Plex. Round 6 closed the explicit rollback write and left the other three open; the test it added set `upNext: false`, which tears the effect down before any of them can fire. The refusal now takes back the CLAIM rather than the latch: a second flag stops `settle` re-asserting watched, and playing again clears both. `FINISH_MARGIN_S` duplicated the backend's constant in a second language with nothing relating them, and its comment had the asymmetry backwards — if the BACKEND's margin grows, this guard starts admitting a rollback the backend reads as a finish, i.e. re-opens the defect above. Pinned by a contract test that reads the Python, and the comment corrected.
40 lines
2.1 KiB
TypeScript
40 lines
2.1 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { expect, it } from "vitest";
|
|
|
|
// One invariant, two languages, no shared wire format — so a test is the only thing that can hold
|
|
// them together.
|
|
//
|
|
// The backend finishes an item when a progress checkpoint lands within `_FINISH_MARGIN_S` of the
|
|
// duration: watched, position cleared, scrobbled to Plex. The player's refused-finish rollback must
|
|
// therefore never post a position inside that window, or the branch named "take the finish back"
|
|
// performs, through the backend, the very write that was just refused (rounds 6 and 7 both landed
|
|
// on this). The frontend mirrors the number so it knows where the window starts.
|
|
//
|
|
// Both were 30 and exactly complementary, but nothing related them, so a backend-only change would
|
|
// have re-opened the defect silently — a rule stated in a comment and not enforced in code, the one
|
|
// thing this repo's house rules single out. The direction matters: the frontend's margin must be at
|
|
// LEAST the backend's. Larger is merely conservative (a rollback skipped where it could have run);
|
|
// smaller is the bug.
|
|
//
|
|
// Both values are read from source rather than imported: `plexPlayerHooks` pulls in the i18n module
|
|
// (and with it `localStorage`), and booting half the app to read one integer would make this test
|
|
// about the module graph instead of about the two numbers.
|
|
function constantIn(path: string, pattern: RegExp): number {
|
|
const source = readFileSync(new URL(path, import.meta.url), "utf8");
|
|
const m = pattern.exec(source);
|
|
// If this fails the constant was renamed or moved — find it and re-point this test, because the
|
|
// invariant it guards did not go away.
|
|
expect(m, `${pattern} not found in ${path}`).not.toBeNull();
|
|
return Number(m![1]);
|
|
}
|
|
|
|
it("never lets the player's finish margin fall below the backend's", () => {
|
|
const backend = constantIn(
|
|
"../../../backend/app/routes/plex/playback.py",
|
|
/^_FINISH_MARGIN_S\s*=\s*(\d+)/m,
|
|
);
|
|
const frontend = constantIn("./plexPlayerHooks.ts", /^const FINISH_MARGIN_S\s*=\s*(\d+)/m);
|
|
expect(backend).toBeGreaterThan(0);
|
|
expect(frontend).toBeGreaterThanOrEqual(backend);
|
|
});
|