File size: 6,065 Bytes
0c85e62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
from __future__ import annotations

import subprocess
import textwrap
from pathlib import Path


REPO_ROOT = Path(__file__).resolve().parents[2]


def test_compare_load_path_returns_before_applysnapshot() -> None:
    # Spec #181 hard constraint: "no applySnapshot() call on the compare path
    # (static guard test)." The Load dialog doubles as the read-only compare
    # chooser via `onChoose`; both load functions MUST short-circuit (return) in
    # onChoose mode BEFORE reaching the destructive applySnapshot. Pin that
    # ordering so a future refactor can't silently reintroduce a destructive
    # load on the compare path.
    src = (REPO_ROOT / "web" / "sidebar" / "modals.js").read_text(encoding="utf-8")
    for fn in ("doNamedLoad", "doAutosaveRestore"):
        fn_start = src.index(f"async function {fn}(")
        choose_idx = src.index("if (onChoose)", fn_start)
        # Match the actual call (with paren), not the word in a comment.
        apply_idx = src.index("applySnapshot(", fn_start)
        assert choose_idx < apply_idx, (
            f"{fn}: the onChoose compare-mode guard must precede applySnapshot"
        )
        assert "return;" in src[choose_idx:apply_idx], (
            f"{fn}: the onChoose guard must return before reaching applySnapshot"
        )


def test_enter_compare_mode_uses_readonly_chooser() -> None:
    # The compare ENTRY path opens the existing Load dialog in read-only choose
    # mode (onChoose), never the destructive apply path.
    src = (REPO_ROOT / "web" / "sidebar" / "tree.js").read_text(encoding="utf-8")
    start = src.index("function enterCompareMode(")
    body = src[start:start + 1400]
    assert "showLoadSnapshotDialog(" in body
    assert "onChoose:" in body, (
        "enterCompareMode must open the Load dialog in read-only choose mode"
    )


def _compare_guard_block(src: str) -> str:
    # The read-only capture-guard block lives between the NAV_ALLOW declaration
    # and the "Forward declarations" marker that follows the `if (compare)` block.
    start = src.index("const NAV_ALLOW =")
    return src[start:src.index("// Forward declarations", start)]


def test_compare_panel_guard_blocks_drag_drop_fully() -> None:
    # #181 read-only guarantee: the comparison panel must neutralize cross-panel
    # drag-and-drop, not just dragstart. A drag begun in the live panel and
    # dropped on a comparison folder row would otherwise persist a live
    # move/archive (handleDndDrop -> persistMutation on the live store).
    guard = _compare_guard_block(
        (REPO_ROOT / "web" / "sidebar" / "tree.js").read_text(encoding="utf-8")
    )
    for evt in ("click", "contextmenu", "dragstart", "dragover", "drop"):
        assert f'addEventListener("{evt}"' in guard, (
            f"compare read-only guard is missing a capture handler for '{evt}'"
        )
    # Every guard listener must be capture-phase (third arg `true`).
    assert guard.count(", true)") >= 5, (
        "all compare-guard listeners must be registered capture-phase"
    )


def test_compare_nav_allow_excludes_group_mode_toggle() -> None:
    # The grouping mode toggle writes a shared global key (GROUP_MODE_KEY), so it
    # must NOT be allow-listed in the read-only comparison panel.
    src = (REPO_ROOT / "web" / "sidebar" / "tree.js").read_text(encoding="utf-8")
    start = src.index("const NAV_ALLOW =")
    nav = src[start:src.index(";", start)]
    assert "koolook-mode-toggle" not in nav, (
        "grouping mode toggle writes a shared global key; drop it from NAV_ALLOW"
    )


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_picks_render_source_override_is_read_only() -> None:
    # Compare mode feeds the comparison panel a snapshot's picks via a
    # render-source override. It must SERVE the override to loadUserPicks but
    # never persist it — clearing returns to live picks (empty under Node,
    # which has no localStorage). Proves the non-destructive guarantee.
    proc = run_node_scenario(
        textwrap.dedent(
            """
            import assert from "node:assert/strict";
            import {
                setPicksRenderSource,
                clearPicksRenderSource,
                loadUserPicks,
            } from "./web/sidebar/picks_store.js";

            assert.deepEqual(loadUserPicks(), []);              // live (no localStorage in Node)
            setPicksRenderSource(["NodeA", "NodeB"]);
            assert.deepEqual(loadUserPicks(), ["NodeA", "NodeB"]);  // override served
            clearPicksRenderSource();
            assert.deepEqual(loadUserPicks(), []);              // live again, untouched
            """
        )
    )
    assert proc.returncode == 0, proc.stderr


def test_workflows_render_source_override_does_not_mutate_live() -> None:
    proc = run_node_scenario(
        textwrap.dedent(
            """
            import assert from "node:assert/strict";
            import {
                setWorkflowsRenderSource,
                clearWorkflowsRenderSource,
                dirOf,
                getAllWorkflowsForExport,
            } from "./web/sidebar/workflows_store.js";

            const liveBefore = JSON.stringify(getAllWorkflowsForExport());
            setWorkflowsRenderSource({
                directories: { Cmp: { workflows: { w: { graph: {} } }, directories: {} } },
            });
            assert.ok(dirOf(["Cmp"]));                          // reads resolve against the override
            clearWorkflowsRenderSource();
            // The live export (workflowsCache) is never touched by the override.
            assert.equal(JSON.stringify(getAllWorkflowsForExport()), liveBefore);
            assert.equal(dirOf(["Cmp"]), undefined);            // override gone -> not in live store
            """
        )
    )
    assert proc.returncode == 0, proc.stderr