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:
co-authored by
Claude Opus 4.8
parent
2f851cf9c1
commit
4efe3ba851
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user