File
Blob: tests/client/lib/affordance-models.test.ts
| 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { deriveEditorAffordance } from "@/client/lib/affordance/editor"; |
| 3 | import { deriveShareDialogAffordance, deriveShareDialogRowAffordance } from "@/client/lib/affordance/share-dialog"; |
| 4 | import { deriveSharePageAffordance } from "@/client/lib/affordance/share-page"; |
| 5 | import { deriveSidebarBaseAffordance, deriveSidebarRowAffordance } from "@/client/lib/affordance/sidebar"; |
| 6 | import { deriveWorkspacePageAffordance } from "@/client/lib/affordance/workspace-page"; |
| 7 | |
| 8 | describe("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 | }); |