Skip to content
File

Blob: tests/client/lib/affordance-models.test.ts

typescript338 lines
1import { describe, expect, it } from "vitest";
2import { deriveEditorAffordance } from "@/client/lib/affordance/editor";
3import { deriveShareDialogAffordance, deriveShareDialogRowAffordance } from "@/client/lib/affordance/share-dialog";
4import { deriveSharePageAffordance } from "@/client/lib/affordance/share-page";
5import { deriveSidebarBaseAffordance, deriveSidebarRowAffordance } from "@/client/lib/affordance/sidebar";
6import { deriveWorkspacePageAffordance } from "@/client/lib/affordance/workspace-page";
7 
8describe("client affordance models", () => {
9 describe("editor affordance", () => {
10 it("keeps canonical page mentions available offline when the document is editable", () => {
11 expect(
12 deriveEditorAffordance({
13 surface: "canonical",
14 pageAccess: "edit",
15 workspaceId: "ws-1",
16 online: false,
17 workspaceRole: "member",
18 }),
19 ).toEqual({
20 documentEditable: true,
21 canInsertPageMentions: true,
22 canInsertImages: false,
23 canUseAiRewrite: false,
24 canUseAiGenerate: false,
25 canSummarizePage: false,
26 canAskPage: false,
27 });
28 });
29 
30 it("enables all AI affordances for writer roles on canonical edit pages", () => {
31 expect(
32 deriveEditorAffordance({
33 surface: "canonical",
34 pageAccess: "edit",
35 workspaceId: "ws-1",
36 online: true,
37 workspaceRole: "member",
38 }),
39 ).toMatchObject({
40 canUseAiRewrite: true,
41 canUseAiGenerate: true,
42 canSummarizePage: true,
43 canAskPage: true,
44 });
45 });
46 
47 it("denies every AI action for guests on canonical edit pages even online", () => {
48 expect(
49 deriveEditorAffordance({
50 surface: "canonical",
51 pageAccess: "edit",
52 workspaceId: "ws-1",
53 online: true,
54 workspaceRole: "guest",
55 }),
56 ).toMatchObject({
57 canUseAiRewrite: false,
58 canUseAiGenerate: false,
59 canSummarizePage: false,
60 canAskPage: false,
61 });
62 });
63 
64 it("disables page mentions in shared editors even when document editing is allowed", () => {
65 expect(
66 deriveEditorAffordance({
67 surface: "shared",
68 pageAccess: "edit",
69 workspaceId: "ws-1",
70 online: true,
71 workspaceRole: "none",
72 }),
73 ).toEqual({
74 documentEditable: true,
75 canInsertPageMentions: false,
76 canInsertImages: true,
77 canUseAiRewrite: false,
78 canUseAiGenerate: false,
79 canSummarizePage: false,
80 canAskPage: false,
81 });
82 });
83 });
84 
85 describe("sidebar affordance", () => {
86 it("hides every sidebar writer affordance for guests — including archive (they cannot create pages)", () => {
87 expect(deriveSidebarBaseAffordance({ workspaceRole: "guest", online: true })).toEqual({
88 createPage: { kind: "hidden" },
89 });
90 expect(deriveSidebarRowAffordance({ workspaceRole: "guest", ownsPage: false, online: true })).toEqual({
91 createSubpage: { kind: "hidden" },
92 movePage: { kind: "hidden" },
93 archivePage: { kind: "hidden" },
94 });
95 expect(deriveSidebarRowAffordance({ workspaceRole: "guest", ownsPage: true, online: true })).toEqual({
96 createSubpage: { kind: "hidden" },
97 movePage: { kind: "hidden" },
98 archivePage: { kind: "hidden" },
99 });
100 });
101 
102 it("shows offline-disabled writer affordances and keeps archive blocked by ownership when the member does not own the page", () => {
103 expect(deriveSidebarBaseAffordance({ workspaceRole: "member", online: false })).toEqual({
104 createPage: { kind: "disabled", reason: "You're offline" },
105 });
106 expect(deriveSidebarRowAffordance({ workspaceRole: "member", ownsPage: false, online: false })).toEqual({
107 createSubpage: { kind: "disabled", reason: "You're offline" },
108 movePage: { kind: "disabled", reason: "You're offline" },
109 archivePage: { kind: "disabled", reason: "Only the page creator can archive this" },
110 });
111 });
112 
113 it("surfaces the offline reason over enabled when the member owns the page", () => {
114 expect(
115 deriveSidebarRowAffordance({ workspaceRole: "member", ownsPage: true, online: false }).archivePage,
116 ).toEqual({ kind: "disabled", reason: "You're offline" });
117 expect(deriveSidebarRowAffordance({ workspaceRole: "member", ownsPage: true, online: true }).archivePage).toEqual(
118 { kind: "enabled" },
119 );
120 });
121 
122 it("hides archive entirely for the none role so non-members see no archive affordance", () => {
123 expect(deriveSidebarRowAffordance({ workspaceRole: "none", ownsPage: false, online: true }).archivePage).toEqual({
124 kind: "hidden",
125 });
126 });
127 });
128 
129 describe("workspace page affordance", () => {
130 it("keeps canonical document editing available offline while disabling online-only actions", () => {
131 expect(
132 deriveWorkspacePageAffordance({
133 accessMode: "member",
134 workspaceRole: "member",
135 pageKind: "doc",
136 pageAccess: "edit",
137 ownsPage: false,
138 workspaceId: "ws-1",
139 online: false,
140 }),
141 ).toMatchObject({
142 kind: "doc",
143 breadcrumbMode: "normal",
144 shareDialog: { kind: "disabled", reason: "You're offline" },
145 editPageMetadata: { kind: "disabled", reason: "You're offline" },
146 archivePage: { kind: "disabled", reason: "Only the page creator can archive this" },
147 editor: {
148 documentEditable: true,
149 canInsertPageMentions: true,
150 canInsertImages: false,
151 },
152 });
153 });
154 
155 it("uses restricted breadcrumbs for guest canonical page views", () => {
156 expect(
157 deriveWorkspacePageAffordance({
158 accessMode: "member",
159 workspaceRole: "guest",
160 pageKind: "doc",
161 pageAccess: "view",
162 ownsPage: false,
163 workspaceId: "ws-1",
164 online: true,
165 }).breadcrumbMode,
166 ).toBe("restricted");
167 });
168 
169 it("forces AI + mention affordances off on canvas pages and populates the canvas slot", () => {
170 const affordance = deriveWorkspacePageAffordance({
171 accessMode: "member",
172 workspaceRole: "member",
173 pageKind: "canvas",
174 pageAccess: "edit",
175 ownsPage: true,
176 workspaceId: "ws-1",
177 online: true,
178 });
179 expect(affordance.kind).toBe("canvas");
180 if (affordance.kind !== "canvas") throw new Error("expected canvas affordance");
181 expect(affordance.canvas).toEqual({ canEdit: true, canInsertImages: true });
182 });
183 });
184 
185 describe("share page affordance", () => {
186 it("shows the view-only badge for view shares and allows uploads for editable shares", () => {
187 expect(
188 deriveSharePageAffordance({
189 pageKind: "doc",
190 pageAccess: "view",
191 workspaceId: "ws-1",
192 online: true,
193 }),
194 ).toMatchObject({
195 kind: "doc",
196 showViewOnlyBadge: true,
197 editor: {
198 documentEditable: false,
199 canInsertPageMentions: false,
200 canInsertImages: false,
201 },
202 });
203 
204 expect(
205 deriveSharePageAffordance({
206 pageKind: "doc",
207 pageAccess: "edit",
208 workspaceId: "ws-1",
209 online: true,
210 }),
211 ).toMatchObject({
212 kind: "doc",
213 showViewOnlyBadge: false,
214 editor: {
215 documentEditable: true,
216 canInsertPageMentions: false,
217 canInsertImages: true,
218 },
219 });
220 });
221 
222 it("gates AI + mentions on shared canvas pages while surfacing the canvas slot", () => {
223 const affordance = deriveSharePageAffordance({
224 pageKind: "canvas",
225 pageAccess: "view",
226 workspaceId: "ws-1",
227 online: true,
228 });
229 expect(affordance.kind).toBe("canvas");
230 if (affordance.kind !== "canvas") throw new Error("expected canvas affordance");
231 expect(affordance.canvas).toEqual({ canEdit: false, canInsertImages: false });
232 });
233 });
234 
235 describe("share dialog affordance", () => {
236 it("lets members create user shares but not link shares, and blocks shareByEmail", () => {
237 expect(
238 deriveShareDialogAffordance({
239 workspaceRole: "member",
240 online: true,
241 hasUserShares: false,
242 hasLinkShares: false,
243 }),
244 ).toEqual({
245 showPeopleSection: true,
246 showLinkSection: false,
247 createUserShare: { kind: "enabled" },
248 createLinkShare: { kind: "hidden" },
249 shareByEmail: false,
250 });
251 });
252 
253 it("shows offline-disabled share actions while keeping populated sections visible, and admins may share by email", () => {
254 expect(
255 deriveShareDialogAffordance({
256 workspaceRole: "admin",
257 online: false,
258 hasUserShares: true,
259 hasLinkShares: true,
260 }),
261 ).toEqual({
262 showPeopleSection: true,
263 showLinkSection: true,
264 createUserShare: { kind: "disabled", reason: "You're offline" },
265 createLinkShare: { kind: "disabled", reason: "You're offline" },
266 shareByEmail: true,
267 });
268 });
269 
270 it("marks owners as able to share by email and members as not", () => {
271 expect(
272 deriveShareDialogAffordance({
273 workspaceRole: "owner",
274 online: true,
275 hasUserShares: false,
276 hasLinkShares: false,
277 }).shareByEmail,
278 ).toBe(true);
279 expect(
280 deriveShareDialogAffordance({
281 workspaceRole: "member",
282 online: true,
283 hasUserShares: false,
284 hasLinkShares: false,
285 }).shareByEmail,
286 ).toBe(false);
287 });
288 
289 it("lets members revoke only qualifying user shares and keeps copy-link local for already-loaded link rows", () => {
290 expect(
291 deriveShareDialogRowAffordance({
292 workspaceRole: "member",
293 online: true,
294 currentUserId: "user-1",
295 share: {
296 id: "share-1",
297 page_id: "page-1",
298 grantee_type: "user",
299 grantee_id: "user-2",
300 permission: "view",
301 link_token: null,
302 created_by: "user-1",
303 created_at: "2026-01-01T00:00:00.000Z",
304 grantee_user: null,
305 },
306 granteeIsWorkspaceMember: true,
307 }),
308 ).toEqual({
309 revoke: { kind: "enabled" },
310 copyLink: { kind: "hidden" },
311 });
312 
313 expect(
314 deriveShareDialogRowAffordance({
315 workspaceRole: "member",
316 online: false,
317 currentUserId: "user-1",
318 share: {
319 id: "share-2",
320 page_id: "page-1",
321 grantee_type: "link",
322 grantee_id: null,
323 permission: "view",
324 link_token: "token-1",
325 created_by: "user-1",
326 created_at: "2026-01-01T00:00:00.000Z",
327 grantee_user: null,
328 },
329 granteeIsWorkspaceMember: false,
330 }),
331 ).toEqual({
332 revoke: { kind: "hidden" },
333 copyLink: { kind: "enabled" },
334 });
335 });
336 });
337});