summaryrefslogtreecommitdiffhomepage
path: root/src/features/tabs/ui.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/tabs/ui.test.ts')
-rw-r--r--src/features/tabs/ui.test.ts52
1 files changed, 44 insertions, 8 deletions
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts
index adfb3fb..c8e07ad 100644
--- a/src/features/tabs/ui.test.ts
+++ b/src/features/tabs/ui.test.ts
@@ -425,6 +425,11 @@ describe("TabList", () => {
});
describe("FLIP animation (motion allowed)", () => {
+ /** Jump every running GSAP animation to `progress` (0–1), delays included. */
+ function seekAnimations(progress: number): void {
+ for (const t of gsap.globalTimeline.getChildren(true, true, false)) t.progress(progress);
+ }
+
// jsdom has no matchMedia, which the grid reads as "no motion" — stub
// the outermost edge so the GSAP Flip path actually runs.
beforeEach(() => {
@@ -448,8 +453,13 @@ describe("TabList", () => {
it("leaves an inert ghost of a closed tab (and its dissolved box) that is never a tab", async () => {
const two = grouped.filter((t) => ["p", "c1", "solo"].includes(t.conversationId));
const { rerender } = render(TabList, { props: { ...props, tabs: two } });
+ // jsdom lays nothing out (every box is 0×0) — give elements a real size.
+ const rect = vi
+ .spyOn(Element.prototype, "getBoundingClientRect")
+ .mockReturnValue(new DOMRect(0, 0, 120, 72));
// Close c1: its card AND p's group box leave.
await rerender({ ...props, tabs: two.filter((t) => t.conversationId !== "c1") });
+ rect.mockRestore();
const ghosts = [...document.querySelectorAll<HTMLElement>("[data-tab-ghost]")];
expect(ghosts).toHaveLength(2);
@@ -465,11 +475,15 @@ describe("TabList", () => {
// A clipping frame around a copy of the tint only — no card copies.
expect(boxGhost.childElementCount).toBe(1);
expect(boxGhost.firstElementChild?.childElementCount).toBe(0);
- // Ghosts SHRINK (a clipped height), they don't fade or scale.
- for (const g of ghosts) {
+ // Ghosts SHRINK (a clipped height), they don't fade or scale: halfway
+ // through, each is shorter than it started; at the end they're gone.
+ const full = ghosts.map((g) => Number.parseFloat(g.style.height));
+ seekAnimations(0.5);
+ ghosts.forEach((g, i) => {
expect(g.style.overflow).toBe("hidden");
- expect(gsap.getTweensOf(g)).toHaveLength(1);
- }
+ expect(g.style.opacity).toBe("");
+ expect(Number.parseFloat(g.style.height)).toBeLessThan(full[i] ?? 0);
+ });
const cardGhost = ghosts.find((g) => g !== boxGhost);
expect(cardGhost?.parentElement).toBe(grid.parentElement);
expect(cardGhost).toHaveTextContent("Child 1");
@@ -479,6 +493,24 @@ describe("TabList", () => {
"solo",
]);
expect(screen.getAllByRole("button", { name: "Close tab" })).toHaveLength(2);
+ seekAnimations(1);
+ expect(document.querySelector("[data-tab-ghost]")).toBeNull();
+ });
+
+ it("measures each element at most twice per change (no layout thrash)", async () => {
+ // Regression: GSAP's Flip plugin cost ~430ms of main-thread work per
+ // close with ~40 nested tabs. The hand-rolled FLIP reads every element's
+ // box once before the change and once after — never per tween.
+ const { rerender } = render(TabList, { props: { ...props, tabs: grouped } });
+ const elements = document.querySelectorAll("[data-flip-id]").length;
+ const spy = vi.spyOn(Element.prototype, "getBoundingClientRect");
+ try {
+ await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") });
+ // 2 per element + 1 host read per ghost (one ghost here: c2's card).
+ expect(spy.mock.calls.length).toBeLessThanOrEqual(2 * elements + 1);
+ } finally {
+ spy.mockRestore();
+ }
});
it("does not ghost anything when a subagent is summoned", async () => {
@@ -487,10 +519,14 @@ describe("TabList", () => {
await rerender({ ...props, tabs: grouped });
expect(document.querySelector("[data-tab-ghost]")).toBeNull();
expect(screen.getAllByRole("tab")).toHaveLength(grouped.length);
- // New cards start fully clipped (0 visible height) and grow in.
- for (const id of ["c1", "c2", "g"]) {
- expect(card(id).style.clipPath).toBe("inset(0% 0% 100% 0%)");
- expect(gsap.getTweensOf(card(id))).toHaveLength(1);
+ // New cards start fully clipped (0 visible height) and grow in from
+ // the top edge; once done no motion style is left behind.
+ const ids = ["c1", "c2", "g"];
+ for (const id of ids) expect(card(id).style.clipPath).toBe("inset(0% 0% 100% 0%)");
+ seekAnimations(1);
+ for (const id of ids) {
+ expect(card(id).style.clipPath).toBe("");
+ expect(card(id).style.transform).toBe("");
}
});
});