summaryrefslogtreecommitdiffhomepage
path: root/src/features/layout/ui/TileRoot.test.ts
blob: 27b8e0a8f058252b3728641423372eaedc4d2c26 (plain)
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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
import type { LayoutTree, SplitNode } from "@dispatch/layout-contract";
import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { createCollapseStore, registerView, unregisterView } from "../index";
import FixtureA from "./fixtures/FixtureA.svelte";
import FixtureB from "./fixtures/FixtureB.svelte";
import FixtureC from "./fixtures/FixtureC.svelte";
import TileRoot from "./TileRoot.svelte";

// Fixture registry: two panel-eligible views + one content-only view (a THIRD
// component, so every fixture testid is unique in the rendered tree).
registerView({ id: "fix-a", label: "Alpha View", component: FixtureA });
registerView({ id: "fix-b", label: "Beta View", component: FixtureB });
registerView({ id: "fix-content", label: "Content", component: FixtureC, panelEligible: false });

function leaf(view: string): { kind: "leaf"; view: string } {
  return { kind: "leaf", view };
}

/** split[ stack[leaf a (grow), leaf content], collapsible panels[leaf b] ] */
function defaultTestTree(): LayoutTree {
  return {
    version: 1,
    root: {
      kind: "split",
      id: "root",
      dir: "row",
      children: [
        {
          size: 1,
          node: {
            kind: "stack",
            id: "main",
            children: [{ grow: 1, node: leaf("fix-a") }, { node: leaf("fix-content") }],
          },
        },
        {
          size: 0,
          basis: 320,
          collapsible: true,
          node: { kind: "panels", id: "side", panels: [leaf("fix-b")] },
        },
      ],
    },
  };
}

/** A bare panels root — panel EDITS without the collapsible chrome in the way. */
function panelsOnlyTree(...views: string[]): LayoutTree {
  return { version: 1, root: { kind: "panels", id: "side", panels: views.map(leaf) } };
}

function sidePanelsOf(tree: LayoutTree | null): string[] {
  if (tree === null) return [];
  const side = (tree.root as SplitNode).children?.[1]?.node;
  return side?.kind === "panels"
    ? side.panels.map((p) => p.view)
    : tree.root.kind === "panels"
      ? tree.root.panels.map((p) => p.view)
      : [];
}

describe("TileRoot", () => {
  it("renders every node kind: split, stack, panels, leaf", () => {
    // A FRESH collapse store per test — the store is stateful chrome.
    render(TileRoot, {
      props: { tree: defaultTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
    });
    // Stack children mount their registered components.
    expect(screen.getByTestId("fixture-a")).toBeInTheDocument();
    expect(screen.getByTestId("fixture-c")).toBeInTheDocument();
    // The panels node renders its panel leaf + the dropdown.
    expect(screen.getByTestId("fixture-b")).toBeInTheDocument();
    expect(screen.getByRole("combobox", { name: "Select a view" })).toBeInTheDocument();
  });

  it("shows a fallback tile (never a crash) for an unknown view id", () => {
    const tree: LayoutTree = {
      version: 1,
      root: { kind: "leaf", view: "not-registered-anywhere" },
    };
    render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
    const fallback = screen.getByTestId("fallback-tile");
    expect(fallback).toHaveTextContent("not-registered-anywhere");
    expect(fallback).toHaveTextContent("View not registered.");
  });

  it("renders nothing for the titlebar slot while it is unset (D-L5)", () => {
    const { container } = render(TileRoot, {
      props: { tree: defaultTestTree(), collapse: createCollapseStore() },
    });
    expect(container.querySelectorAll("[data-testid='fallback-tile']")).toHaveLength(0);
  });

  it("a set titlebar renders its node (the slot works; nothing populates it today)", () => {
    const tree: LayoutTree = {
      version: 1,
      titlebar: { kind: "leaf", view: "fix-a" },
      root: { kind: "leaf", view: "fix-b" },
    };
    render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
    expect(screen.getByTestId("fixture-a")).toBeInTheDocument();
    expect(screen.getByTestId("fixture-b")).toBeInTheDocument();
  });

  it("the collapsible child reflects the collapse store and toggles through it", async () => {
    const collapse = createCollapseStore({ viewportWidth: 1024 });
    const { container } = render(TileRoot, { props: { tree: defaultTestTree(), collapse } });
    const aside = container.querySelector<HTMLElement>("[data-testid='collapsible-child']");
    expect(aside).not.toBeNull();
    // Wide viewport default: open → full basis width.
    expect(aside).toHaveAttribute("data-open", "true");
    expect(aside?.style.width).toBe("320px");

    await collapse.toggle("side");
    expect(aside).toHaveAttribute("data-open", "false");
    expect(aside?.style.width).toBe("0px");
  });

  it("a dropdown swap emits replaceView and the owner receives the changed tree", async () => {
    const user = userEvent.setup();
    let changed: LayoutTree | null = null;
    render(TileRoot, {
      props: {
        tree: panelsOnlyTree("fix-b"),
        collapse: createCollapseStore(),
        onTreeChange: (t) => (changed = t),
      },
    });

    await user.selectOptions(screen.getByRole("combobox", { name: "Select a view" }), "fix-a");
    expect(sidePanelsOf(changed)).toEqual(["fix-a"]);
    // TileRoot is a CONTROLLED component: applying the op is the owner's job
    // (App re-renders with the new tree; the test doesn't feed it back).
  });

  it("the + button emits insertPanel pre-selecting the first eligible view", async () => {
    const user = userEvent.setup();
    let changed: LayoutTree | null = null;
    render(TileRoot, {
      props: {
        tree: panelsOnlyTree("fix-b"),
        collapse: createCollapseStore(),
        onTreeChange: (t) => (changed = t),
      },
    });

    await user.click(screen.getByRole("button", { name: "Add view" }));
    expect(sidePanelsOf(changed)).toEqual(["fix-b", "fix-a"]);
  });

  it("the ✕ button emits removeNode for that panel (absent on the first panel)", async () => {
    const user = userEvent.setup();
    let changed: LayoutTree | null = null;
    render(TileRoot, {
      props: {
        tree: panelsOnlyTree("fix-b", "fix-a"),
        collapse: createCollapseStore(),
        onTreeChange: (t) => (changed = t),
      },
    });

    // Only panels after the first can be removed — like ViewSidebar today.
    const removeButtons = screen.getAllByRole("button", { name: "Remove view" });
    expect(removeButtons).toHaveLength(1);
    await user.click(removeButtons[0] as HTMLElement);
    expect(sidePanelsOf(changed)).toEqual(["fix-b"]);
  });

  it("an invalid edit is refused: the first panel has no remove button", () => {
    const tree = panelsOnlyTree("fix-b");
    let changed: LayoutTree | null = null;
    const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
    try {
      render(TileRoot, {
        props: { tree, collapse: createCollapseStore(), onTreeChange: (t) => (changed = t) },
      });
      expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull();
      expect(changed).toBeNull();
    } finally {
      consoleError.mockRestore();
    }
  });

  it("a stack child with grow renders in a flex-grown wrapper; others natural", () => {
    const { container } = render(TileRoot, {
      props: { tree: defaultTestTree(), collapse: createCollapseStore() },
    });
    const grown = container.querySelector("[data-testid='fixture-a']")?.closest(".min-h-0");
    expect(grown?.getAttribute("style")).toContain("flex: 1 1 0%");
    const natural = container.querySelector("[data-testid='fixture-c']")?.closest(".min-h-0");
    expect(natural?.getAttribute("style")).toContain("flex: 0 0 auto");
  });

  it("the dropdown lists panel-eligible views only (decision 5)", () => {
    render(TileRoot, { props: { tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() } });
    const select = screen.getByRole("combobox", { name: "Select a view" });
    const labels = [...select.querySelectorAll("option")].map((o) => o.textContent);
    expect(labels).toContain("Alpha View");
    expect(labels).toContain("Beta View");
    expect(labels).not.toContain("Content");
  });

  it("an unregistered view id inside a panel renders the fallback (not a crash)", () => {
    render(TileRoot, {
      props: { tree: panelsOnlyTree("ghost-view"), collapse: createCollapseStore() },
    });
    expect(screen.getByTestId("fallback-tile")).toBeInTheDocument();
  });
});

describe("TileRoot registry cleanup", () => {
  it("unregisters the fixture views (the registry is module-global per test file)", () => {
    unregisterView("fix-a");
    unregisterView("fix-b");
    unregisterView("fix-content");
    const tree: LayoutTree = { version: 1, root: { kind: "leaf", view: "fix-a" } };
    const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
    try {
      render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
      // Unregistered → the fallback tile, not a crash.
      expect(screen.getByTestId("fallback-tile")).toBeInTheDocument();
    } finally {
      consoleError.mockRestore();
    }
  });
});