1import type { ScopedThreadRef } from "@t3tools/contracts";
2import { assert, describe, it } from "vite-plus/test";
3import {
4 buildVisibleToastLayout,
5 hasVisibleToastAction,
6 shouldHideCollapsedToastContent,
7 shouldRenderThreadScopedToast,
8} from "./toast.logic";
9
10describe("hasVisibleToastAction", () => {
11 it("treats a labeled action as visible", () => {
12 assert.equal(hasVisibleToastAction({ children: "Update" }), true);
13 });
14
15 it("hides an explicit empty action used to clear a previous CTA", () => {
16 assert.equal(hasVisibleToastAction({ children: null }), false);
17 assert.equal(hasVisibleToastAction({ children: "" }), false);
18 assert.equal(hasVisibleToastAction(undefined), false);
19 });
20});
21
22describe("shouldHideCollapsedToastContent", () => {
23 it("keeps a single visible toast readable", () => {
24 assert.equal(shouldHideCollapsedToastContent(0, 1), false);
25 });
26
27 it("keeps the front-most toast readable in a visible stack", () => {
28 assert.equal(shouldHideCollapsedToastContent(0, 3), false);
29 });
30
31 it("hides non-front toasts until the stack is expanded", () => {
32 assert.equal(shouldHideCollapsedToastContent(1, 3), true);
33 });
34});
35
36describe("buildVisibleToastLayout", () => {
37 it("computes indices and offsets from the visible subset", () => {
38 const visibleToasts = [
39 { id: "a", height: 48 },
40 { id: "b", height: 72 },
41 { id: "c", height: 24 },
42 ];
43
44 const layout = buildVisibleToastLayout(visibleToasts);
45
46 assert.equal(layout.frontmostHeight, 48);
47 assert.deepEqual(
48 layout.items.map(({ toast, visibleIndex, offsetY }) => ({
49 id: toast.id,
50 visibleIndex,
51 offsetY,
52 })),
53 [
54 { id: "a", visibleIndex: 0, offsetY: 0 },
55 { id: "b", visibleIndex: 1, offsetY: 48 },
56 { id: "c", visibleIndex: 2, offsetY: 120 },
57 ],
58 );
59 });
60
61 it("reflows live toasts forward when the front toast is dismissed", () => {
62 const visibleToasts = [
63 { id: "a", height: 48, transitionStatus: "ending" as const },
64 { id: "b", height: 72 },
65 { id: "c", height: 24 },
66 ];
67
68 const layout = buildVisibleToastLayout(visibleToasts);
69
70 // frontmost height should be the first live toast, not the ending one
71 assert.equal(layout.frontmostHeight, 72);
72 assert.deepEqual(
73 layout.items.map(({ toast, visibleIndex, offsetY }) => ({
74 id: toast.id,
75 visibleIndex,
76 offsetY,
77 })),
78 [
79 // Ending toast stays at its front slot; data-ending-style drives its exit
80 { id: "a", visibleIndex: 0, offsetY: 0 },
81 // Live toasts get fresh indices starting at 0 so they move up in sync
82 { id: "b", visibleIndex: 0, offsetY: 0 },
83 { id: "c", visibleIndex: 1, offsetY: 72 },
84 ],
85 );
86 });
87
88 it("keeps a non-front ending toast at its current slot so it exits straight", () => {
89 const visibleToasts = [
90 { id: "a", height: 48 },
91 { id: "b", height: 72, transitionStatus: "ending" as const },
92 { id: "c", height: 24 },
93 ];
94
95 const layout = buildVisibleToastLayout(visibleToasts);
96
97 // front toast stays, so frontmost height is unchanged
98 assert.equal(layout.frontmostHeight, 48);
99 assert.deepEqual(
100 layout.items.map(({ toast, visibleIndex, offsetY }) => ({
101 id: toast.id,
102 visibleIndex,
103 offsetY,
104 })),
105 [
106 // Front live toast — unaffected
107 { id: "a", visibleIndex: 0, offsetY: 0 },
108 // Ending toast keeps its pre-dismissal slot so its horizontal exit
109 // originates from where the user saw it (not from Y=0).
110 { id: "b", visibleIndex: 1, offsetY: 48 },
111 // Live toast behind "b" slides forward into the vacated slot.
112 { id: "c", visibleIndex: 1, offsetY: 48 },
113 ],
114 );
115 });
116
117 it("treats missing heights as zero", () => {
118 const layout = buildVisibleToastLayout([
119 { id: "a" },
120 { id: "b", height: undefined },
121 { id: "c", height: 30 },
122 ]);
123
124 assert.equal(layout.frontmostHeight, 0);
125 assert.deepEqual(
126 layout.items.map(({ toast, offsetY }) => ({
127 id: toast.id,
128 offsetY,
129 })),
130 [
131 { id: "a", offsetY: 0 },
132 { id: "b", offsetY: 0 },
133 { id: "c", offsetY: 0 },
134 ],
135 );
136 });
137});
138
139describe("shouldRenderThreadScopedToast", () => {
140 const activeThreadRef = {
141 environmentId: "environment-a",
142 threadId: "thread-1",
143 } as ScopedThreadRef;
144
145 it("renders a toast scoped to the active thread ref", () => {
146 assert.equal(
147 shouldRenderThreadScopedToast(
148 {
149 threadRef: activeThreadRef,
150 },
151 activeThreadRef,
152 ),
153 true,
154 );
155 });
156
157 it("hides a scoped toast when the environment differs", () => {
158 assert.equal(
159 shouldRenderThreadScopedToast(
160 {
161 threadRef: {
162 environmentId: "environment-b",
163 threadId: "thread-1",
164 } as ScopedThreadRef,
165 },
166 activeThreadRef,
167 ),
168 false,
169 );
170 });
171
172 it("keeps legacy thread-id scoped toasts working", () => {
173 assert.equal(
174 shouldRenderThreadScopedToast(
175 {
176 threadId: "thread-1" as never,
177 },
178 activeThreadRef,
179 ),
180 true,
181 );
182 });
183});