| from __future__ import annotations |
|
|
| import subprocess |
| import textwrap |
| from pathlib import Path |
|
|
|
|
| REPO_ROOT = Path(__file__).resolve().parents[2] |
|
|
|
|
| def _tree_src() -> str: |
| return (REPO_ROOT / "web" / "sidebar" / "tree.js").read_text(encoding="utf-8") |
|
|
|
|
| def _compare_guard_block(src: str) -> str: |
| |
| |
| start = src.index("const NAV_ALLOW =") |
| return src[start:src.index("// Forward declarations", start)] |
|
|
|
|
| def run_node_scenario(source: str) -> subprocess.CompletedProcess[str]: |
| return subprocess.run( |
| ["node", "--input-type=module"], |
| input=source, |
| cwd=REPO_ROOT, |
| text=True, |
| capture_output=True, |
| check=False, |
| ) |
|
|
|
|
| |
| |
| |
| |
| def test_get_workflow_entry_from_store_lookup() -> None: |
| proc = run_node_scenario( |
| textwrap.dedent( |
| """ |
| import assert from "node:assert/strict"; |
| import { getWorkflowEntryFromStore } from "./web/sidebar/snapshot_diff.js"; |
| |
| const store = { directories: { |
| Basics: { |
| workflows: { txt2img: { graph: { nodes: [1, 2] }, tags: ["fast", "fav"] } }, |
| directories: { |
| Sub: { workflows: { nested: { graph: { n: 9 } } }, directories: {} }, |
| }, |
| }, |
| } }; |
| |
| const top = getWorkflowEntryFromStore(store, "Basics/txt2img"); |
| assert.ok(top, "top-level entry resolves by full path"); |
| assert.deepEqual(top.graph, { nodes: [1, 2] }); |
| assert.deepEqual(top.tags, ["fast", "fav"]); |
| |
| const nested = getWorkflowEntryFromStore(store, "Basics/Sub/nested"); |
| assert.ok(nested, "nested entry resolves"); |
| assert.deepEqual(nested.graph, { n: 9 }); |
| |
| assert.equal(getWorkflowEntryFromStore(store, "Basics/missing"), null); |
| assert.equal(getWorkflowEntryFromStore(null, "Basics/txt2img"), null); |
| """ |
| ) |
| ) |
| assert proc.returncode == 0, proc.stderr |
|
|
|
|
| def test_pull_in_copy_round_trips_graph_and_tags_into_live_kit() -> None: |
| |
| |
| |
| |
| |
| proc = run_node_scenario( |
| textwrap.dedent( |
| """ |
| import assert from "node:assert/strict"; |
| import { getWorkflowEntryFromStore } from "./web/sidebar/snapshot_diff.js"; |
| import { |
| saveWorkflowEntry, |
| addTag, |
| getAllWorkflowsForExport, |
| getWorkflowTags, |
| getWorkflowGraph, |
| } from "./web/sidebar/workflows_store.js"; |
| |
| const snapshot = { directories: { |
| Basics: { |
| workflows: { txt2img: { graph: { nodes: [1, 2] }, tags: ["fast", "fav"] } }, |
| directories: {}, |
| }, |
| } }; |
| const snapshotBefore = JSON.stringify(snapshot); |
| |
| // What the pull-in does on Save. The graph is DEEP-CLONED first so |
| // the live entry never aliases the snapshot's graph object (the |
| // aliasing guard at the end pins this). |
| const entry = getWorkflowEntryFromStore(snapshot.directories ? snapshot : null, "Basics/txt2img"); |
| const graphForCopy = JSON.parse(JSON.stringify(entry.graph)); |
| const res = saveWorkflowEntry(["Basics"], "txt2img", graphForCopy, { module: false }); |
| assert.ok(res, "entry saved into the live kit"); |
| for (const tag of entry.tags) addTag(["Basics"], "txt2img", tag); |
| |
| const live = getAllWorkflowsForExport(); |
| const copied = live.directories.Basics.workflows.txt2img; |
| assert.deepEqual(copied.graph, { nodes: [1, 2] }, "graph rides the copy"); |
| assert.deepEqual([...getWorkflowTags(["Basics"], "txt2img")].sort(), ["fast", "fav"], "tags ride the copy"); |
| |
| // Same path/name preserved — never rekeyed. |
| assert.ok(live.directories.Basics.workflows.txt2img, "path + name preserved"); |
| |
| // Source snapshot object untouched by the copy itself. |
| assert.equal(JSON.stringify(snapshot), snapshotBefore, "source snapshot is not mutated by the copy"); |
| |
| // Aliasing guard (#197 read-only promise): the copy must DEEP-CLONE the |
| // graph, not alias the snapshot's object into the live store. Otherwise a |
| // later edit of the copied (live) workflow bleeds back into the read-only |
| // snapshot. Probe the real failure: mutate the live graph in place, then |
| // assert the snapshot's graph is still pristine. |
| getWorkflowGraph(["Basics"], "txt2img").nodes.push(99); |
| assert.deepEqual( |
| snapshot.directories.Basics.workflows.txt2img.graph, |
| { nodes: [1, 2] }, |
| "editing the live copy must not mutate the source snapshot's graph", |
| ); |
| """ |
| ) |
| ) |
| assert proc.returncode == 0, proc.stderr |
|
|
|
|
| |
| |
| |
| |
| |
| |
| def test_copy_engine_path_preserving_create_and_merge() -> None: |
| proc = run_node_scenario( |
| textwrap.dedent( |
| """ |
| import assert from "node:assert/strict"; |
| import { copyWorkflowIntoStore } from "./web/sidebar/workflows_store.js"; |
| |
| // Empty destination -> the whole path is auto-created. |
| const dest = { directories: {} }; |
| let r = copyWorkflowIntoStore(dest, ["Basics", "Upscale"], "myWf", { nodes: [1] }, { tags: ["fav"], module: false }); |
| assert.equal(r.status, "added"); |
| assert.equal(r.finalName, "myWf"); |
| const leaf = dest.directories.Basics.directories.Upscale.workflows.myWf; |
| assert.deepEqual(leaf.graph, { nodes: [1] }, "graph lands at the same path"); |
| assert.deepEqual(leaf.tags, ["fav"], "tags ride"); |
| |
| // A second, different item into an EXISTING folder merges in place — |
| // no duplicate Basics/Upscale folder is created. |
| r = copyWorkflowIntoStore(dest, ["Basics", "Upscale"], "otherWf", { nodes: [2] }, {}); |
| assert.equal(r.status, "added"); |
| assert.equal(Object.keys(dest.directories).length, 1, "no duplicate top folder"); |
| assert.equal(Object.keys(dest.directories.Basics.directories.Upscale.workflows).length, 2, "merged into existing folder"); |
| """ |
| ) |
| ) |
| assert proc.returncode == 0, proc.stderr |
|
|
|
|
| def test_copy_engine_collision_skip_identical_and_keep_both() -> None: |
| proc = run_node_scenario( |
| textwrap.dedent( |
| """ |
| import assert from "node:assert/strict"; |
| import { copyWorkflowIntoStore } from "./web/sidebar/workflows_store.js"; |
| |
| const dest = { directories: { Basics: { workflows: { wf: { graph: { nodes: [1] }, tags: [] } }, directories: {} } } }; |
| |
| // Identical graph at the same path -> skip (already there), no new key. |
| let r = copyWorkflowIntoStore(dest, ["Basics"], "wf", { nodes: [1] }, {}); |
| assert.equal(r.status, "skipped"); |
| assert.equal(Object.keys(dest.directories.Basics.workflows).length, 1, "skip adds nothing"); |
| |
| // Different graph, same name -> keep both with a suffix. |
| r = copyWorkflowIntoStore(dest, ["Basics"], "wf", { nodes: [9] }, { sourceLabel: "snapA" }); |
| assert.equal(r.status, "kept-both"); |
| assert.equal(r.finalName, "wf (from snapA)"); |
| assert.ok(dest.directories.Basics.workflows["wf (from snapA)"], "kept-both entry exists"); |
| assert.deepEqual(dest.directories.Basics.workflows.wf.graph, { nodes: [1] }, "original untouched"); |
| """ |
| ) |
| ) |
| assert proc.returncode == 0, proc.stderr |
|
|
|
|
| def test_copy_folder_bulk_merge() -> None: |
| |
| |
| |
| proc = run_node_scenario( |
| textwrap.dedent( |
| """ |
| import assert from "node:assert/strict"; |
| import { copyFolderIntoStore } from "./web/sidebar/workflows_store.js"; |
| |
| const source = { directories: { |
| Basics: { |
| workflows: { a: { graph: { n: 1 } }, old: { graph: { n: 9 }, archived: true } }, |
| directories: { Sub: { workflows: { b: { graph: { n: 2 }, tags: ["x"] } }, directories: {} } }, |
| }, |
| Other: { workflows: { z: { graph: { n: 3 } } }, directories: {} }, |
| } }; |
| |
| // Copy just Basics (recursively), path-preserving, archived skipped. |
| const target = { directories: {} }; |
| let s = copyFolderIntoStore(target, source, ["Basics"], { sourceLabel: "snap" }); |
| assert.equal(s.total, 2, "2 active under Basics (archived skipped)"); |
| assert.equal(s.added, 2); |
| assert.deepEqual(target.directories.Basics.workflows.a.graph, { n: 1 }, "a at Basics/a"); |
| assert.deepEqual(target.directories.Basics.directories.Sub.workflows.b.graph, { n: 2 }, "b at Basics/Sub/b"); |
| assert.deepEqual(target.directories.Basics.directories.Sub.workflows.b.tags, ["x"], "tags ride"); |
| assert.ok(!("old" in target.directories.Basics.workflows), "archived not copied"); |
| assert.ok(!("Other" in target.directories), "only the chosen folder copied"); |
| |
| // Identical re-copy -> all skips. |
| s = copyFolderIntoStore(target, source, ["Basics"], {}); |
| assert.equal(s.skipped, 2); |
| assert.equal(s.added, 0); |
| |
| // dirSegs [] copies the entire tree. |
| const all = { directories: {} }; |
| s = copyFolderIntoStore(all, source, [], {}); |
| assert.equal(s.total, 3, "whole-tree copy gets every active workflow"); |
| assert.ok(all.directories.Other.workflows.z, "Other/z copied"); |
| """ |
| ) |
| ) |
| assert proc.returncode == 0, proc.stderr |
|
|
|
|
| def test_copy_engine_rejects_prototype_polluting_names() -> None: |
| |
| |
| |
| proc = run_node_scenario( |
| textwrap.dedent( |
| """ |
| import assert from "node:assert/strict"; |
| import { copyWorkflowIntoStore } from "./web/sidebar/workflows_store.js"; |
| |
| const target = { directories: {} }; |
| assert.equal(copyWorkflowIntoStore(target, ["Basics"], "__proto__", { n: 1 }, {}).status, "failed"); |
| assert.equal(copyWorkflowIntoStore(target, ["__proto__"], "wf", { n: 1 }, {}).status, "failed"); |
| assert.equal(copyWorkflowIntoStore(target, ["Basics"], "constructor", { n: 1 }, {}).status, "failed"); |
| assert.equal(({}).n, undefined, "Object.prototype not polluted"); |
| """ |
| ) |
| ) |
| assert proc.returncode == 0, proc.stderr |
|
|
|
|
| def test_copy_engine_deep_clones_graph() -> None: |
| |
| |
| proc = run_node_scenario( |
| textwrap.dedent( |
| """ |
| import assert from "node:assert/strict"; |
| import { copyWorkflowIntoStore } from "./web/sidebar/workflows_store.js"; |
| |
| const srcGraph = { nodes: [1, 2] }; |
| const dest = { directories: {} }; |
| copyWorkflowIntoStore(dest, ["D"], "wf", srcGraph, {}); |
| dest.directories.D.workflows.wf.graph.nodes.push(99); |
| assert.deepEqual(srcGraph, { nodes: [1, 2] }, "source graph must be untouched by edits to the copy"); |
| """ |
| ) |
| ) |
| assert proc.returncode == 0, proc.stderr |
|
|
|
|
| def test_diff_excludes_archived_entries() -> None: |
| |
| |
| |
| |
| proc = run_node_scenario( |
| textwrap.dedent( |
| """ |
| import assert from "node:assert/strict"; |
| import { diffWorkflows, getWorkflowEntryFromStore } from "./web/sidebar/snapshot_diff.js"; |
| |
| const working = { directories: {} }; |
| const comparison = { directories: { |
| Basics: { workflows: { |
| active: { graph: { n: 1 } }, |
| "old (archived 2026)": { graph: { n: 2 }, archived: true }, |
| }, directories: {} }, |
| } }; |
| |
| const status = diffWorkflows(working, comparison); |
| assert.equal(status["Basics/active"], "new", "active entry is still classified"); |
| assert.ok(!("Basics/old (archived 2026)" in status), "archived entry excluded from the diff"); |
| |
| // ...but it can still be looked up for an explicit copy. |
| assert.ok(getWorkflowEntryFromStore(comparison, "Basics/old (archived 2026)"), "archived stays copyable"); |
| """ |
| ) |
| ) |
| assert proc.returncode == 0, proc.stderr |
|
|
|
|
| |
| |
| |
| |
| def test_readonly_guard_routes_pullable_rows_to_copy_menu() -> None: |
| guard = _compare_guard_block(_tree_src()) |
| |
| |
| assert "data-koolook-node-type" in guard, "node rows must be pull-able" |
| assert "data-koolook-wf-path" in guard, "workflow rows must be pull-able" |
| assert "showContextMenu" in guard, "the pull-in opens a context menu" |
| assert "pullIn" in guard, "the guard consults the pull-in descriptor" |
|
|
|
|
| def test_readonly_guard_keeps_all_capture_handlers() -> None: |
| |
| |
| guard = _compare_guard_block(_tree_src()) |
| for evt in ("click", "contextmenu", "dragstart", "dragover", "drop"): |
| assert f'addEventListener("{evt}"' in guard, f"missing capture handler for '{evt}'" |
| assert guard.count(", true)") >= 5, "all guard listeners must be capture-phase" |
|
|
|
|
| def _copy_layer(src: str) -> str: |
| |
| |
| start = src.index("function makePullIn(") |
| return src[start:src.index("function applyCompareTint(", start)] |
|
|
|
|
| def test_copy_layer_is_additive_and_bidirectional() -> None: |
| layer = _copy_layer(_tree_src()) |
| |
| assert "copyWorkflowIntoLiveStore(" in layer, "B->A copies into the live store" |
| assert "copyWorkflowIntoStore(" in layer, "A->B copies into the snapshot store" |
| assert "copyNodeToLive(" in layer and "copyNodeToSnapshot(" in layer, "node copy is bidirectional" |
| assert "addToMyPicks(" in layer, "B->A node copy adds to favorites" |
| |
| assert "applySnapshot" not in layer, "the copy layer must never apply the snapshot" |
|
|
|
|
| def test_copy_is_path_preserving_without_a_folder_picker() -> None: |
| |
| |
| layer = _copy_layer(_tree_src()) |
| assert "splitWfPath(" in layer, "the destination path is derived from the source path" |
| assert "copyWorkflowIntoLiveStore(dirSegs" in layer, "live copy preserves the source dir path" |
| assert "showSaveWorkflowModal" not in layer, "the copy no longer opens a folder-picker modal" |
|
|
|
|
| def test_snapshot_edits_are_in_memory_not_per_copy_disk_writes() -> None: |
| |
| |
| |
| src = _tree_src() |
| start = src.index("function copyWorkflowToSnapshot(") |
| body = src[start:src.index("\nfunction ", start + 10)] |
| assert "compareSnapshot.workflows" in body, "A->B edits the in-memory snapshot" |
| assert "compareDirty = true" in body, "A->B marks the snapshot dirty" |
| assert "writePreset" not in body, "A->B must not write to disk per copy" |
|
|
|
|
| def test_snapshot_save_writes_named_file_and_clears_dirty() -> None: |
| |
| |
| src = _tree_src() |
| start = src.index("function saveCompareSnapshot(") |
| body = src[start:src.index("\nfunction ", start + 10)] |
| assert "writePreset(name, snapshotRef)" in body, "Save writes a named file to the default dir" |
| assert "compareDirty = false" in body, "Save clears the dirty flag" |
| assert "compareDefaultSaveName(" in body, "Save pre-fills the shadowed / loaded name" |
| |
| |
| helper_start = src.index("function compareDefaultSaveName(") |
| helper = src[helper_start:src.index("\nfunction ", helper_start + 10)] |
| assert "_autosave" in helper and 'slice(0, -"_autosave".length)' in helper, ( |
| "derive the named parent from the autosave dir" |
| ) |
|
|
|
|
| def test_snapshot_save_guards_overwrite_and_sanitizes() -> None: |
| |
| |
| |
| src = _tree_src() |
| start = src.index("function saveCompareSnapshot(") |
| body = src[start:src.index("\nfunction ", start + 10)] |
| assert "sanitizeName(" in body, "Save sanitizes the name like other writePreset callers" |
| assert "presetExists(" in body, "Save checks existence before writing" |
| assert "exists === null" in body, "Save aborts if the library can't be reached" |
| assert "showConfirmModal(" in body, "Save confirms before overwriting an existing snapshot" |
| assert "compareSaving" in body, "Save guards against overlapping in-flight writes" |
|
|
|
|
| def test_exit_warns_on_unsaved_snapshot_edits() -> None: |
| src = _tree_src() |
| start = src.index("function exitCompareMode(") |
| body = src[start:src.index("\nfunction ", start + 10)] |
| assert "compareDirty" in body and "showConfirmModal" in body, ( |
| "exiting with unsaved snapshot edits must confirm before discarding them" |
| ) |
|
|
|
|
| def test_save_state_is_its_own_stripe_separate_from_orientation() -> None: |
| |
| |
| src = _tree_src() |
| assert "function buildSaveStripe(" in src, "a dedicated save stripe exists" |
| assert "koolook-compare-savebar" in src, "the save stripe is its own element" |
| assert "koolook-savebar-btn" in src, "the save stripe carries the Save button" |
| assert "koolook-savebar-unsaved" in src, "the save stripe shows the unsaved state" |
| |
| foot_start = src.index("function labelColumnFoot(") |
| foot = src[foot_start:src.index("\nfunction ", foot_start + 10)] |
| assert "savebtn" not in foot and "auto-save" not in foot, "footer is orientation-only now" |
|
|
|
|
| def test_autosave_target_shows_clean_name_not_long_path() -> None: |
| |
| |
| src = _tree_src() |
| assert "function compareSnapName(" in src, "snapshot display name is cleaned" |
| assert "· autosave" in src, "autosave targets are marked, named-parent shown" |
| assert 'slice(0, -"_autosave".length)' in src, "derive the named parent from the autosave dir" |
|
|
|
|
| def test_source_target_footers_and_legend_present() -> None: |
| src = _tree_src() |
| assert "function labelColumnFoot(" in src, "each column gets a SOURCE/TARGET footer" |
| assert '"TARGET"' in src and '"SOURCE"' in src, "footers label source vs target" |
| assert "koolook-compare-colfoot" in src, "footer stripe is rendered" |
|
|
|
|
| def test_diff_filter_browser_present() -> None: |
| src = _tree_src() |
| assert "let compareFilter" in src, "compare host tracks the diff filter" |
| assert "function applyCompareFilter(" in src, "a post-render pass applies the filter" |
| assert "koolook-cmp-chip" in src, "the legend doubles as clickable filter chips" |
| |
| flt = src[src.index("function applyCompareFilter("):src.index("function applyCompareFilter(") + 1400] |
| assert "koolook-children" in flt, "filter walks the nested folder DOM" |
| assert 'style.display = "none"' in flt, "filter hides non-matching rows/folders" |
|
|
|
|
| def test_diff_highlight_and_filter_follow_the_source_side() -> None: |
| |
| |
| |
| |
| src = _tree_src() |
| assert "const sourceHost = liveIsActive ? rightCol.host : leftCol.host" in src, ( |
| "tint + filter apply to the source (inactive) panel, flipping with the swap" |
| ) |
| assert "applyCompareTint(sourceHost," in src and "applyCompareFilter(sourceHost)" in src |
| tint = src[src.index("function applyCompareTint("):src.index("function applyCompareTint(") + 900] |
| assert "diffPicks(targetPicks, sourcePicks)" in tint, "new picks = in source, absent from target" |
| assert "diffWorkflows(targetStore, sourceStore)" in tint, "new/diff workflows = source vs target" |
|
|
|
|
| def test_compare_panels_have_isolated_expansion_state() -> None: |
| |
| |
| |
| |
| src = _tree_src() |
| assert "let pathStates = new Map()" in src, "the live expansion map must be swappable" |
| assert "const comparePathStates = new Map()" in src, "snapshot panel needs its own expansion map" |
| assert "function withComparePathStates(" in src |
| assert "withSnapshotSource(snapshot, () => withComparePathStates(fn))" in src, ( |
| "compare tree renders must run under the compare expansion map" |
| ) |
| |
| assert "function openCompareDestPath(" in src |
| assert "openCompareDestPath(dirSegs)" in src, "A->B copy opens its dest folder" |
|
|
|
|
| def test_folder_pull_in_is_wired() -> None: |
| |
| |
| src = _tree_src() |
| assert "row.dataset.koolookFolderPath = path" in src, "folder rows carry their tree path" |
| guard = _compare_guard_block(src) |
| assert "data-koolook-folder-path" in guard, "guard recognizes folder rows" |
| assert "copyFolder(" in guard, "guard offers the folder copy" |
| layer = _copy_layer(src) |
| assert "copyFolderIntoLiveStore(" in layer, "B->A bulk copy into the live kit" |
| assert "copyFolderIntoStore(" in layer, "A->B bulk copy into the snapshot" |
| assert "function copyFolderToLive(" in src and "function copyFolderToSnapshot(" in src |
|
|
|
|
| def test_swap_toggle_is_wired_in_compare_host() -> None: |
| src = _tree_src() |
| assert "let activeSide" in src, "compare host tracks which side is active" |
| assert "function swapCompareSides(" in src, "a swap handler flips the active side" |
| assert "koolook-compare-swap" in src, "the swap control is rendered in the compare host" |
|
|
|
|
| def test_autosave_dir_threaded_for_writeback() -> None: |
| |
| |
| src = (REPO_ROOT / "web" / "sidebar" / "modals.js").read_text(encoding="utf-8") |
| assert "dir: item.dir" in src, "compare onChoose must pass the autosave dir for write-back" |
|
|