test: JS test harness for scene→root matching (regression guard)

Extracts the pure global-prompt scene-matching kernel into scene_align.js
(dual browser/CommonJS export) and adds a node test suite so the "stale
scene anchor after reorder/insert/delete" class can't silently regress
again (it has recurred twice).

- scene_align.js: matchParsedToRoots(parsedTexts, rootTexts) — the two-pass
  content-first matcher (exact text + nearest-position tiebreak, then
  leftover roots in order), index-based and dependency-free. Exposed as
  window.SceneAlign in the browser (same pattern as window.PipelineCaps)
  and module.exports under node.
- store.js _alignScenesFromParsed now delegates the matching to the kernel
  and maps indices back to roots — single source of truth, no duplicated
  logic. Behaviour is identical to the previous inline two passes.
- index.html loads scene_align.js before store.js.
- scene_align.test.js: 11 cases via node's built-in runner — reorder,
  edit-in-place, insert-at-front, delete-middle, reorder+edit-one,
  edit+insert+keep, irreducible, duplicate-text tiebreak, empty-text,
  no-roots, used-once. Run with `npm test` (node --test) in this dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-28 20:14:44 +03:00
co-authored by Claude Opus 4.8
parent 2f851cf9c1
commit 4efe3ba851
5 changed files with 153 additions and 24 deletions
+1
View File
@@ -110,6 +110,7 @@
<script src="view_bus.js"></script>
<script src="history.js"></script>
<script src="media_picker.js"></script>
<script src="scene_align.js"></script>
<script src="store.js"></script>
<script src="models.js"></script>
<script src="workflow_import.js"></script>
+8
View File
@@ -0,0 +1,8 @@
{
"name": "funpack-movie-editor-frontend",
"private": true,
"description": "Movie Editor browser frontend. Plain <script>-tag modules (no bundler). Node is used only to unit-test pure logic extracted into *.js with dual browser/CommonJS exports (e.g. scene_align.js).",
"scripts": {
"test": "node --test"
}
}
+53
View File
@@ -0,0 +1,53 @@
// Pure scene→root matching for the global-prompt re-parse, extracted from store.js so it
// can be unit-tested in node (no DOM/state deps). The global prompt is authoritative; when
// it is re-parsed into scene slots, each slot must reuse the old scene root that owns its
// CONTENT — not the root that merely sits at the same array index. Matching by POSITION
// first was the "stale scene" bug: a reorder/insert/delete shifted indices and glued one
// scene's text onto another scene's anchor+render (then that wrong mapping got saved, so it
// survived restarts and key-clears). See scene_align.test.js for the regression cases.
//
// matchParsedToRoots(parsedTexts, rootTexts) → matchByIdx, where matchByIdx[i] is the index
// of the old root that parsed slot i should reuse, or -1 for none (→ ghost/new upstream).
// Both inputs are arrays of ALREADY-normalized text (caller applies its own normalizer so
// this stays the single source of matching truth without duplicating normalization).
(function (root) {
function matchParsedToRoots(parsedTexts, rootTexts) {
const n = parsedTexts.length;
const m = rootTexts.length;
const matchByIdx = new Array(n).fill(-1);
const usedRoot = new Array(m).fill(false);
// Pass 1 — exact text match, nearest original position breaks ties. Pins every
// unchanged scene to its own root through any reorder / insert / delete.
for (let i = 0; i < n; i++) {
const pt = parsedTexts[i];
if (!pt) continue; // empty slots can only be positional (Pass 2)
let best = -1;
let bestDist = Infinity;
for (let ri = 0; ri < m; ri++) {
if (usedRoot[ri]) continue;
if (rootTexts[ri] !== pt) continue;
const dist = Math.abs(ri - i);
if (dist < bestDist) { best = ri; bestDist = dist; }
}
if (best >= 0) { matchByIdx[i] = best; usedRoot[best] = true; }
}
// Pass 2 — leftover slots inherit the leftover roots IN ORDER (in-place text edits keep
// their render/anchor). A slot with no root left stays -1 → a fresh scene upstream.
let rr = 0;
for (let i = 0; i < n; i++) {
if (matchByIdx[i] >= 0) continue;
while (rr < m && usedRoot[rr]) rr++;
if (rr >= m) break;
matchByIdx[i] = rr;
usedRoot[rr] = true;
}
return matchByIdx;
}
const api = { matchParsedToRoots };
if (typeof module !== "undefined" && module.exports) module.exports = api; // node test
if (root) root.SceneAlign = api; // browser global
})(typeof window !== "undefined" ? window : null);
+79
View File
@@ -0,0 +1,79 @@
// Regression tests for the global-prompt scene→root matching (scene_align.js).
// Run: node --test movie_editor/frontend/scene_align.test.js
//
// These guard the "stale scene anchor after reorder/insert/delete" bug: a scene's render +
// anchor live under its root id, so the matcher must bind a parsed slot to the root that
// owns its CONTENT, never to whatever root sits at the same array index.
const test = require("node:test");
const assert = require("node:assert");
const { matchParsedToRoots } = require("./scene_align.js");
// Helper: resolve each parsed slot to the root's anchor label it would inherit ("new" = -1).
function anchors(roots, parsedTexts) {
const rootTexts = roots.map((r) => r.text);
const idx = matchParsedToRoots(parsedTexts, rootTexts);
return idx.map((ri) => (ri >= 0 ? roots[ri].anchor : "new"));
}
const A = { text: "a cat", anchor: "anchorA" };
const B = { text: "a dog", anchor: "anchorB" };
const C = { text: "a bird", anchor: "anchorC" };
test("reorder only — anchors follow content, not slot", () => {
assert.deepStrictEqual(anchors([A, B], ["a dog", "a cat"]), ["anchorB", "anchorA"]);
});
test("edit in place — render/anchor preserved", () => {
assert.deepStrictEqual(anchors([A], ["a black cat"]), ["anchorA"]);
});
test("insert at front — existing anchors stay, new slot is fresh", () => {
assert.deepStrictEqual(
anchors([A, B], ["new scene", "a cat", "a dog"]),
["new", "anchorA", "anchorB"],
);
});
test("delete middle — survivors keep anchors", () => {
assert.deepStrictEqual(anchors([A, B, C], ["a cat", "a bird"]), ["anchorA", "anchorC"]);
});
test("reorder + edit one — unchanged text pins it, edited one follows", () => {
assert.deepStrictEqual(anchors([A, B], ["a dog", "a big cat"]), ["anchorB", "anchorA"]);
});
test("edit + insert + keep — leftover edit takes leftover root, new is fresh", () => {
assert.deepStrictEqual(
anchors([A, B], ["a cat v2", "NEW", "a dog"]),
["anchorA", "new", "anchorB"],
);
});
test("irreducible (both reordered AND fully rewritten) — positional fallback", () => {
// No unchanged text to anchor on → genuinely ambiguous; positional guess is acceptable.
assert.deepStrictEqual(anchors([A, B], ["a big dog", "a cute cat"]), ["anchorA", "anchorB"]);
});
test("duplicate texts — nearest position breaks the tie", () => {
const X0 = { text: "x", anchor: "x0" };
const X2 = { text: "x", anchor: "x2" };
// roots at index 0 and 2; parsed at index 0 and 3 → 0↔x0, 3↔x2.
assert.deepStrictEqual(
anchors([X0, { text: "y", anchor: "y1" }, X2], ["x", "y", "z", "x"]),
["x0", "y1", "new", "x2"],
);
});
test("empty parsed text is positional only (never exact-matches a root)", () => {
// Blank slot must not steal a root by 'exact text'; it takes a leftover root positionally.
assert.deepStrictEqual(anchors([A, B], ["", "a cat"]), ["anchorB", "anchorA"]);
});
test("no old roots — every slot is fresh", () => {
assert.deepStrictEqual(anchors([], ["a", "b"]), ["new", "new"]);
});
test("each root used at most once", () => {
const idx = matchParsedToRoots(["a cat", "a cat"], ["a cat"]);
assert.deepStrictEqual(idx, [0, -1]); // second duplicate slot finds no root left
});
+12 -24
View File
@@ -407,31 +407,19 @@
const next = [];
let ghostsOut = [...(ghosts || [])];
const parsed = parsedScenes || [];
const matchByIdx = new Array(parsed.length).fill(null);
// Pass 1 — exact text match, closest original position wins ties.
for (let i = 0; i < parsed.length; i++) {
const pt = _normalizeSceneText(parsed[i].text);
if (!pt) continue;
let best = null, bestDist = Infinity;
for (let ri = 0; ri < oldRoots.length; ri++) {
const root = oldRoots[ri];
if (usedOld.has(root.id)) continue;
if (_normalizeSceneText(root.text) !== pt) continue;
const dist = Math.abs(ri - i);
if (dist < bestDist) { best = root; bestDist = dist; }
}
if (best) { matchByIdx[i] = best; _markGenUnitUsed(genUnitId(best), usedOld, oldAll); }
}
// Pass 2 — remaining slots take the remaining roots in order (in-place edits).
const remainingRoots = oldRoots.filter((r) => !usedOld.has(r.id));
let rr = 0;
for (let i = 0; i < parsed.length; i++) {
if (matchByIdx[i] || rr >= remainingRoots.length) continue;
const root = remainingRoots[rr++];
matchByIdx[i] = root;
_markGenUnitUsed(genUnitId(root), usedOld, oldAll);
// Match each parsed slot to the old root that owns its CONTENT — exact text first
// (nearest original position breaks ties), then leftover slots take leftover roots in
// order. The pure index-based kernel lives in scene_align.js so the two passes can be
// unit-tested in node (see scene_align.test.js). Both sides are normalized identically
// here before matching, so the kernel can compare raw strings.
const matchIdx = window.SceneAlign.matchParsedToRoots(
parsed.map((p) => _normalizeSceneText(p.text)),
oldRoots.map((r) => _normalizeSceneText(r.text)),
);
const matchByIdx = matchIdx.map((ri) => (ri >= 0 ? oldRoots[ri] : null));
for (const root of matchByIdx) {
if (root) _markGenUnitUsed(genUnitId(root), usedOld, oldAll);
}
// Materialize: matched root → reuse (keeps id/source/render); else ghost by text; else new.