Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'test: comprehensive landing-utils coverage (94 tests)' (#252) from test/batch11-landing-utils into main

scott dee5a919 39275bea

+688
+688
tests/landing-utils.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + compareDocuments, 4 + sortDocuments, 5 + toggleStar, 6 + starredIdsSet, 7 + addToTrash, 8 + restoreFromTrash, 9 + purgeExpiredTrash, 10 + isInTrash, 11 + removeFromTrash, 12 + partitionDocuments, 13 + createFolder, 14 + renameFolder, 15 + deleteFolder, 16 + moveToFolder, 17 + getDocsInFolder, 18 + buildBreadcrumbs, 19 + clearFolderAssignments, 20 + filterBySearch, 21 + validateUsername, 22 + trackRecentDoc, 23 + getRecentDocs, 24 + SORT_OPTIONS, 25 + DEFAULT_SORT, 26 + } from '../src/landing-utils.js'; 27 + import type { DocumentMeta, TrashEntry, Folder, FolderAssignments, StarMap } from '../src/landing-types.js'; 28 + 29 + // --- Helpers --- 30 + 31 + function makeDoc(overrides: Partial<DocumentMeta> & { id: string }): DocumentMeta { 32 + return { 33 + type: 'doc', 34 + name_encrypted: null, 35 + deleted_at: null, 36 + tags: null, 37 + created_at: '2026-01-01T00:00:00Z', 38 + updated_at: '2026-01-01T00:00:00Z', 39 + ...overrides, 40 + }; 41 + } 42 + 43 + // ===================================================================== 44 + // CONSTANTS 45 + // ===================================================================== 46 + 47 + describe('landing-utils constants', () => { 48 + it('SORT_OPTIONS contains expected fields', () => { 49 + expect(SORT_OPTIONS).toContain('updated'); 50 + expect(SORT_OPTIONS).toContain('created'); 51 + expect(SORT_OPTIONS).toContain('name'); 52 + expect(SORT_OPTIONS).toContain('type'); 53 + expect(SORT_OPTIONS).toHaveLength(4); 54 + }); 55 + 56 + it('DEFAULT_SORT is updated', () => { 57 + expect(DEFAULT_SORT).toBe('updated'); 58 + }); 59 + }); 60 + 61 + // ===================================================================== 62 + // SORTING 63 + // ===================================================================== 64 + 65 + describe('compareDocuments', () => { 66 + const docA = makeDoc({ id: 'a', _decryptedName: 'Alpha', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-03T00:00:00Z', type: 'doc' }); 67 + const docB = makeDoc({ id: 'b', _decryptedName: 'Beta', created_at: '2026-01-02T00:00:00Z', updated_at: '2026-01-02T00:00:00Z', type: 'sheet' }); 68 + const docC = makeDoc({ id: 'c', _decryptedName: 'Charlie', created_at: '2026-01-03T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', type: 'doc' }); 69 + 70 + it('sorts by name alphabetically', () => { 71 + expect(compareDocuments(docA, docB, 'name')).toBeLessThan(0); 72 + expect(compareDocuments(docB, docA, 'name')).toBeGreaterThan(0); 73 + expect(compareDocuments(docA, docA, 'name')).toBe(0); 74 + }); 75 + 76 + it('sorts by name case-insensitively', () => { 77 + const upper = makeDoc({ id: 'u', _decryptedName: 'ZEBRA' }); 78 + const lower = makeDoc({ id: 'l', _decryptedName: 'apple' }); 79 + expect(compareDocuments(lower, upper, 'name')).toBeLessThan(0); 80 + }); 81 + 82 + it('uses fallback name for docs without _decryptedName', () => { 83 + const noName = makeDoc({ id: 'nn' }); 84 + // "Encrypted Document" vs "Alpha" 85 + expect(compareDocuments(noName, docA, 'name')).toBeGreaterThan(0); 86 + }); 87 + 88 + it('sorts by created date (newest first)', () => { 89 + expect(compareDocuments(docA, docC, 'created')).toBeGreaterThan(0); // C is newer 90 + expect(compareDocuments(docC, docA, 'created')).toBeLessThan(0); 91 + }); 92 + 93 + it('sorts by updated date (newest first)', () => { 94 + expect(compareDocuments(docA, docC, 'updated')).toBeLessThan(0); // A is newer updated 95 + expect(compareDocuments(docC, docA, 'updated')).toBeGreaterThan(0); 96 + }); 97 + 98 + it('sorts by type, then by updated descending', () => { 99 + // doc < sheet alphabetically 100 + expect(compareDocuments(docA, docB, 'type')).toBeLessThan(0); 101 + // Same type: compare by updated_at descending 102 + expect(compareDocuments(docA, docC, 'type')).toBeLessThan(0); // A has newer updated_at 103 + }); 104 + 105 + it('defaults to updated sort for unknown sort field', () => { 106 + expect(compareDocuments(docA, docC, 'unknown')).toBeLessThan(0); 107 + }); 108 + 109 + it('handles empty date strings', () => { 110 + const noDate = makeDoc({ id: 'nd', updated_at: '', created_at: '' }); 111 + // Empty string sorts before any date 112 + expect(compareDocuments(docA, noDate, 'updated')).toBeLessThan(0); 113 + }); 114 + }); 115 + 116 + describe('sortDocuments', () => { 117 + const docs = [ 118 + makeDoc({ id: 'c', _decryptedName: 'Charlie', updated_at: '2026-01-01T00:00:00Z' }), 119 + makeDoc({ id: 'a', _decryptedName: 'Alpha', updated_at: '2026-01-03T00:00:00Z' }), 120 + makeDoc({ id: 'b', _decryptedName: 'Beta', updated_at: '2026-01-02T00:00:00Z' }), 121 + ]; 122 + 123 + it('sorts by name', () => { 124 + const sorted = sortDocuments(docs, 'name'); 125 + expect(sorted.map(d => d.id)).toEqual(['a', 'b', 'c']); 126 + }); 127 + 128 + it('sorts by updated (default)', () => { 129 + const sorted = sortDocuments(docs, 'updated'); 130 + expect(sorted.map(d => d.id)).toEqual(['a', 'b', 'c']); 131 + }); 132 + 133 + it('starred items come first', () => { 134 + const starred = new Set(['c']); 135 + const sorted = sortDocuments(docs, 'name', starred); 136 + expect(sorted[0].id).toBe('c'); 137 + // Rest sorted by name 138 + expect(sorted[1].id).toBe('a'); 139 + expect(sorted[2].id).toBe('b'); 140 + }); 141 + 142 + it('multiple starred items sorted among themselves', () => { 143 + const starred = new Set(['c', 'b']); 144 + const sorted = sortDocuments(docs, 'name', starred); 145 + // Both starred, sorted by name: Beta < Charlie 146 + expect(sorted[0].id).toBe('b'); 147 + expect(sorted[1].id).toBe('c'); 148 + expect(sorted[2].id).toBe('a'); 149 + }); 150 + 151 + it('does not mutate original array', () => { 152 + const original = [...docs]; 153 + sortDocuments(docs, 'name'); 154 + expect(docs.map(d => d.id)).toEqual(original.map(d => d.id)); 155 + }); 156 + 157 + it('handles empty array', () => { 158 + expect(sortDocuments([], 'name')).toEqual([]); 159 + }); 160 + }); 161 + 162 + // ===================================================================== 163 + // STARS / FAVORITES 164 + // ===================================================================== 165 + 166 + describe('toggleStar', () => { 167 + it('stars an unstarred document', () => { 168 + const result = toggleStar({}, 'doc1'); 169 + expect(result.doc1).toBe(true); 170 + }); 171 + 172 + it('unstars a starred document', () => { 173 + const result = toggleStar({ doc1: true }, 'doc1'); 174 + expect(result).not.toHaveProperty('doc1'); 175 + }); 176 + 177 + it('does not mutate the original map', () => { 178 + const original: StarMap = { doc1: true }; 179 + toggleStar(original, 'doc1'); 180 + expect(original.doc1).toBe(true); 181 + }); 182 + 183 + it('preserves other stars', () => { 184 + const result = toggleStar({ doc1: true, doc2: true }, 'doc1'); 185 + expect(result.doc2).toBe(true); 186 + expect(result).not.toHaveProperty('doc1'); 187 + }); 188 + }); 189 + 190 + describe('starredIdsSet', () => { 191 + it('converts star map to set', () => { 192 + const set = starredIdsSet({ a: true, b: true }); 193 + expect(set.has('a')).toBe(true); 194 + expect(set.has('b')).toBe(true); 195 + expect(set.size).toBe(2); 196 + }); 197 + 198 + it('returns empty set for null', () => { 199 + expect(starredIdsSet(null).size).toBe(0); 200 + }); 201 + 202 + it('returns empty set for undefined', () => { 203 + expect(starredIdsSet(undefined).size).toBe(0); 204 + }); 205 + 206 + it('returns empty set for empty object', () => { 207 + expect(starredIdsSet({}).size).toBe(0); 208 + }); 209 + }); 210 + 211 + // ===================================================================== 212 + // TRASH / SOFT DELETE 213 + // ===================================================================== 214 + 215 + describe('addToTrash', () => { 216 + it('adds a document to empty trash', () => { 217 + const result = addToTrash([], 'doc1', 1000); 218 + expect(result).toEqual([{ id: 'doc1', deletedAt: 1000 }]); 219 + }); 220 + 221 + it('appends to existing trash', () => { 222 + const existing: TrashEntry[] = [{ id: 'doc1', deletedAt: 1000 }]; 223 + const result = addToTrash(existing, 'doc2', 2000); 224 + expect(result).toHaveLength(2); 225 + expect(result[1]).toEqual({ id: 'doc2', deletedAt: 2000 }); 226 + }); 227 + 228 + it('does not add duplicates', () => { 229 + const existing: TrashEntry[] = [{ id: 'doc1', deletedAt: 1000 }]; 230 + const result = addToTrash(existing, 'doc1', 2000); 231 + expect(result).toHaveLength(1); 232 + expect(result[0].deletedAt).toBe(1000); // keeps original timestamp 233 + }); 234 + 235 + it('does not mutate original array', () => { 236 + const original: TrashEntry[] = []; 237 + addToTrash(original, 'doc1', 1000); 238 + expect(original).toHaveLength(0); 239 + }); 240 + }); 241 + 242 + describe('restoreFromTrash', () => { 243 + it('removes document from trash', () => { 244 + const trash: TrashEntry[] = [{ id: 'doc1', deletedAt: 1000 }, { id: 'doc2', deletedAt: 2000 }]; 245 + const result = restoreFromTrash(trash, 'doc1'); 246 + expect(result).toHaveLength(1); 247 + expect(result[0].id).toBe('doc2'); 248 + }); 249 + 250 + it('returns same array if doc not in trash', () => { 251 + const trash: TrashEntry[] = [{ id: 'doc1', deletedAt: 1000 }]; 252 + const result = restoreFromTrash(trash, 'doc99'); 253 + expect(result).toHaveLength(1); 254 + }); 255 + 256 + it('handles empty trash', () => { 257 + expect(restoreFromTrash([], 'doc1')).toEqual([]); 258 + }); 259 + }); 260 + 261 + describe('purgeExpiredTrash', () => { 262 + const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000; 263 + 264 + it('purges entries older than 30 days', () => { 265 + const trash: TrashEntry[] = [ 266 + { id: 'old', deletedAt: 1000 }, 267 + { id: 'new', deletedAt: THIRTY_DAYS + 500 }, 268 + ]; 269 + const result = purgeExpiredTrash(trash, THIRTY_DAYS + 1000); 270 + expect(result.expired).toEqual(['old']); 271 + expect(result.kept).toEqual([{ id: 'new', deletedAt: THIRTY_DAYS + 500 }]); 272 + }); 273 + 274 + it('purges entries exactly at 30 days', () => { 275 + const trash: TrashEntry[] = [{ id: 'exact', deletedAt: 0 }]; 276 + const result = purgeExpiredTrash(trash, THIRTY_DAYS); 277 + expect(result.expired).toEqual(['exact']); 278 + expect(result.kept).toEqual([]); 279 + }); 280 + 281 + it('keeps entries under 30 days', () => { 282 + const trash: TrashEntry[] = [{ id: 'recent', deletedAt: 100 }]; 283 + const result = purgeExpiredTrash(trash, THIRTY_DAYS + 99); 284 + expect(result.expired).toEqual([]); 285 + expect(result.kept).toHaveLength(1); 286 + }); 287 + 288 + it('handles empty trash', () => { 289 + const result = purgeExpiredTrash([], Date.now()); 290 + expect(result.expired).toEqual([]); 291 + expect(result.kept).toEqual([]); 292 + }); 293 + }); 294 + 295 + describe('isInTrash', () => { 296 + const trash: TrashEntry[] = [{ id: 'doc1', deletedAt: 1000 }]; 297 + 298 + it('returns true for trashed doc', () => { 299 + expect(isInTrash(trash, 'doc1')).toBe(true); 300 + }); 301 + 302 + it('returns false for non-trashed doc', () => { 303 + expect(isInTrash(trash, 'doc2')).toBe(false); 304 + }); 305 + 306 + it('returns false for empty trash', () => { 307 + expect(isInTrash([], 'doc1')).toBe(false); 308 + }); 309 + }); 310 + 311 + describe('removeFromTrash', () => { 312 + it('permanently removes entry', () => { 313 + const trash: TrashEntry[] = [{ id: 'doc1', deletedAt: 1000 }, { id: 'doc2', deletedAt: 2000 }]; 314 + const result = removeFromTrash(trash, 'doc1'); 315 + expect(result).toHaveLength(1); 316 + expect(result[0].id).toBe('doc2'); 317 + }); 318 + }); 319 + 320 + describe('partitionDocuments', () => { 321 + const docs = [ 322 + makeDoc({ id: 'a' }), 323 + makeDoc({ id: 'b' }), 324 + makeDoc({ id: 'c' }), 325 + ]; 326 + const trash: TrashEntry[] = [{ id: 'b', deletedAt: 1000 }]; 327 + 328 + it('separates active and trashed documents', () => { 329 + const { active, trashed } = partitionDocuments(docs, trash); 330 + expect(active.map(d => d.id)).toEqual(['a', 'c']); 331 + expect(trashed.map(d => d.id)).toEqual(['b']); 332 + }); 333 + 334 + it('returns all active when trash is empty', () => { 335 + const { active, trashed } = partitionDocuments(docs, []); 336 + expect(active).toHaveLength(3); 337 + expect(trashed).toHaveLength(0); 338 + }); 339 + 340 + it('handles empty docs', () => { 341 + const { active, trashed } = partitionDocuments([], trash); 342 + expect(active).toHaveLength(0); 343 + expect(trashed).toHaveLength(0); 344 + }); 345 + }); 346 + 347 + // ===================================================================== 348 + // FOLDERS 349 + // ===================================================================== 350 + 351 + describe('createFolder', () => { 352 + it('adds a new folder', () => { 353 + const result = createFolder([], 'My Folder', 'folder-abc'); 354 + expect(result).toHaveLength(1); 355 + expect(result[0].name).toBe('My Folder'); 356 + expect(result[0].id).toBe('folder-abc'); 357 + }); 358 + 359 + it('does not mutate original array', () => { 360 + const original: Folder[] = []; 361 + createFolder(original, 'Test', 'folder-xyz'); 362 + expect(original).toHaveLength(0); 363 + }); 364 + 365 + it('appends to existing folders', () => { 366 + const existing: Folder[] = [{ id: 'f1', name: 'First', createdAt: 1000 }]; 367 + const result = createFolder(existing, 'Second', 'f2'); 368 + expect(result).toHaveLength(2); 369 + expect(result[0].id).toBe('f1'); 370 + expect(result[1].id).toBe('f2'); 371 + }); 372 + }); 373 + 374 + describe('renameFolder', () => { 375 + const folders: Folder[] = [ 376 + { id: 'f1', name: 'Old Name', createdAt: 1000 }, 377 + { id: 'f2', name: 'Other', createdAt: 2000 }, 378 + ]; 379 + 380 + it('renames the correct folder', () => { 381 + const result = renameFolder(folders, 'f1', 'New Name'); 382 + expect(result[0].name).toBe('New Name'); 383 + expect(result[1].name).toBe('Other'); 384 + }); 385 + 386 + it('preserves other fields', () => { 387 + const result = renameFolder(folders, 'f1', 'New'); 388 + expect(result[0].createdAt).toBe(1000); 389 + }); 390 + 391 + it('returns same structure if folder not found', () => { 392 + const result = renameFolder(folders, 'f99', 'New'); 393 + expect(result.map(f => f.name)).toEqual(['Old Name', 'Other']); 394 + }); 395 + }); 396 + 397 + describe('deleteFolder', () => { 398 + const folders: Folder[] = [ 399 + { id: 'f1', name: 'First', createdAt: 1000 }, 400 + { id: 'f2', name: 'Second', createdAt: 2000 }, 401 + ]; 402 + 403 + it('removes the folder', () => { 404 + const result = deleteFolder(folders, 'f1'); 405 + expect(result).toHaveLength(1); 406 + expect(result[0].id).toBe('f2'); 407 + }); 408 + 409 + it('returns same array if folder not found', () => { 410 + expect(deleteFolder(folders, 'f99')).toHaveLength(2); 411 + }); 412 + }); 413 + 414 + describe('moveToFolder', () => { 415 + it('assigns doc to folder', () => { 416 + const result = moveToFolder({}, 'doc1', 'f1'); 417 + expect(result.doc1).toBe('f1'); 418 + }); 419 + 420 + it('removes doc from folder with null', () => { 421 + const result = moveToFolder({ doc1: 'f1' }, 'doc1', null); 422 + expect(result).not.toHaveProperty('doc1'); 423 + }); 424 + 425 + it('removes doc from folder with undefined', () => { 426 + const result = moveToFolder({ doc1: 'f1' }, 'doc1', undefined); 427 + expect(result).not.toHaveProperty('doc1'); 428 + }); 429 + 430 + it('moves doc between folders', () => { 431 + const result = moveToFolder({ doc1: 'f1' }, 'doc1', 'f2'); 432 + expect(result.doc1).toBe('f2'); 433 + }); 434 + 435 + it('does not mutate original', () => { 436 + const original: FolderAssignments = { doc1: 'f1' }; 437 + moveToFolder(original, 'doc1', null); 438 + expect(original.doc1).toBe('f1'); 439 + }); 440 + }); 441 + 442 + describe('getDocsInFolder', () => { 443 + const docs = [ 444 + makeDoc({ id: 'a' }), 445 + makeDoc({ id: 'b' }), 446 + makeDoc({ id: 'c' }), 447 + ]; 448 + const assignments: FolderAssignments = { a: 'f1', b: 'f2' }; 449 + 450 + it('returns docs in specific folder', () => { 451 + const result = getDocsInFolder(docs, assignments, 'f1'); 452 + expect(result.map(d => d.id)).toEqual(['a']); 453 + }); 454 + 455 + it('returns unassigned docs for null folderId (root)', () => { 456 + const result = getDocsInFolder(docs, assignments, null); 457 + expect(result.map(d => d.id)).toEqual(['c']); 458 + }); 459 + 460 + it('returns unassigned docs for undefined folderId', () => { 461 + const result = getDocsInFolder(docs, assignments, undefined); 462 + expect(result.map(d => d.id)).toEqual(['c']); 463 + }); 464 + 465 + it('returns empty for folder with no docs', () => { 466 + expect(getDocsInFolder(docs, assignments, 'f99')).toEqual([]); 467 + }); 468 + 469 + it('returns all docs if no assignments', () => { 470 + const result = getDocsInFolder(docs, {}, null); 471 + expect(result).toHaveLength(3); 472 + }); 473 + }); 474 + 475 + describe('buildBreadcrumbs', () => { 476 + const folders: Folder[] = [ 477 + { id: 'f1', name: 'Projects', createdAt: 1000 }, 478 + { id: 'f2', name: 'Archive', createdAt: 2000 }, 479 + ]; 480 + 481 + it('returns only root for null folderId', () => { 482 + const crumbs = buildBreadcrumbs(folders, null); 483 + expect(crumbs).toEqual([{ id: null, name: 'All Documents' }]); 484 + }); 485 + 486 + it('returns root + folder for valid folderId', () => { 487 + const crumbs = buildBreadcrumbs(folders, 'f1'); 488 + expect(crumbs).toHaveLength(2); 489 + expect(crumbs[0]).toEqual({ id: null, name: 'All Documents' }); 490 + expect(crumbs[1]).toEqual({ id: 'f1', name: 'Projects' }); 491 + }); 492 + 493 + it('returns only root for unknown folderId', () => { 494 + const crumbs = buildBreadcrumbs(folders, 'f99'); 495 + expect(crumbs).toHaveLength(1); 496 + }); 497 + }); 498 + 499 + describe('clearFolderAssignments', () => { 500 + it('removes all assignments for a folder', () => { 501 + const assignments: FolderAssignments = { doc1: 'f1', doc2: 'f1', doc3: 'f2' }; 502 + const result = clearFolderAssignments(assignments, 'f1'); 503 + expect(result).toEqual({ doc3: 'f2' }); 504 + }); 505 + 506 + it('returns same assignments if folder not used', () => { 507 + const assignments: FolderAssignments = { doc1: 'f1' }; 508 + const result = clearFolderAssignments(assignments, 'f99'); 509 + expect(result).toEqual({ doc1: 'f1' }); 510 + }); 511 + 512 + it('handles empty assignments', () => { 513 + expect(clearFolderAssignments({}, 'f1')).toEqual({}); 514 + }); 515 + }); 516 + 517 + // ===================================================================== 518 + // SEARCH 519 + // ===================================================================== 520 + 521 + describe('filterBySearch', () => { 522 + const docs = [ 523 + makeDoc({ id: 'a', _decryptedName: 'Budget Report' }), 524 + makeDoc({ id: 'b', _decryptedName: 'Meeting Notes' }), 525 + makeDoc({ id: 'c', _decryptedName: 'Budget Forecast' }), 526 + ]; 527 + 528 + it('filters by partial name match', () => { 529 + const result = filterBySearch(docs, 'budget'); 530 + expect(result.map(d => d.id)).toEqual(['a', 'c']); 531 + }); 532 + 533 + it('is case-insensitive', () => { 534 + const result = filterBySearch(docs, 'MEETING'); 535 + expect(result).toHaveLength(1); 536 + expect(result[0].id).toBe('b'); 537 + }); 538 + 539 + it('returns all docs for null query', () => { 540 + expect(filterBySearch(docs, null)).toHaveLength(3); 541 + }); 542 + 543 + it('returns all docs for undefined query', () => { 544 + expect(filterBySearch(docs, undefined)).toHaveLength(3); 545 + }); 546 + 547 + it('returns all docs for empty string', () => { 548 + expect(filterBySearch(docs, '')).toHaveLength(3); 549 + }); 550 + 551 + it('returns all docs for whitespace-only query', () => { 552 + expect(filterBySearch(docs, ' ')).toHaveLength(3); 553 + }); 554 + 555 + it('trims query whitespace', () => { 556 + const result = filterBySearch(docs, ' budget '); 557 + expect(result).toHaveLength(2); 558 + }); 559 + 560 + it('uses fallback name for unencrypted docs', () => { 561 + const encrypted = [makeDoc({ id: 'x' })]; // no _decryptedName 562 + const result = filterBySearch(encrypted, 'encrypted'); 563 + expect(result).toHaveLength(1); 564 + }); 565 + 566 + it('returns empty for no match', () => { 567 + expect(filterBySearch(docs, 'zzzzz')).toHaveLength(0); 568 + }); 569 + }); 570 + 571 + // ===================================================================== 572 + // USERNAME 573 + // ===================================================================== 574 + 575 + describe('validateUsername', () => { 576 + it('accepts valid name', () => { 577 + expect(validateUsername('Alice')).toEqual({ valid: true }); 578 + }); 579 + 580 + it('rejects empty string', () => { 581 + const result = validateUsername(''); 582 + expect(result.valid).toBe(false); 583 + expect(result.error).toContain('empty'); 584 + }); 585 + 586 + it('rejects whitespace-only', () => { 587 + const result = validateUsername(' '); 588 + expect(result.valid).toBe(false); 589 + }); 590 + 591 + it('rejects name over 50 chars', () => { 592 + const result = validateUsername('a'.repeat(51)); 593 + expect(result.valid).toBe(false); 594 + expect(result.error).toContain('50'); 595 + }); 596 + 597 + it('accepts name at exactly 50 chars', () => { 598 + expect(validateUsername('a'.repeat(50))).toEqual({ valid: true }); 599 + }); 600 + 601 + it('trims whitespace before length check', () => { 602 + // 50 chars + surrounding spaces 603 + expect(validateUsername(' ' + 'a'.repeat(50) + ' ')).toEqual({ valid: true }); 604 + }); 605 + }); 606 + 607 + // ===================================================================== 608 + // RECENT DOCUMENTS 609 + // ===================================================================== 610 + 611 + describe('trackRecentDoc', () => { 612 + it('prepends new doc to empty list', () => { 613 + expect(trackRecentDoc([], 'doc1')).toEqual(['doc1']); 614 + }); 615 + 616 + it('prepends new doc to existing list', () => { 617 + expect(trackRecentDoc(['doc1', 'doc2'], 'doc3')).toEqual(['doc3', 'doc1', 'doc2']); 618 + }); 619 + 620 + it('deduplicates and moves to front', () => { 621 + expect(trackRecentDoc(['doc1', 'doc2', 'doc3'], 'doc2')).toEqual(['doc2', 'doc1', 'doc3']); 622 + }); 623 + 624 + it('caps at maxSize (default 10)', () => { 625 + const ids = Array.from({ length: 10 }, (_, i) => `doc${i}`); 626 + const result = trackRecentDoc(ids, 'new'); 627 + expect(result).toHaveLength(10); 628 + expect(result[0]).toBe('new'); 629 + expect(result[9]).toBe('doc8'); // doc9 dropped 630 + }); 631 + 632 + it('caps at custom maxSize', () => { 633 + const result = trackRecentDoc(['a', 'b', 'c'], 'new', 2); 634 + expect(result).toEqual(['new', 'a']); 635 + }); 636 + 637 + it('does not mutate original', () => { 638 + const original = ['doc1', 'doc2']; 639 + trackRecentDoc(original, 'doc3'); 640 + expect(original).toEqual(['doc1', 'doc2']); 641 + }); 642 + }); 643 + 644 + describe('getRecentDocs', () => { 645 + const allDocs = [ 646 + makeDoc({ id: 'a', _decryptedName: 'Alpha' }), 647 + makeDoc({ id: 'b', _decryptedName: 'Beta' }), 648 + makeDoc({ id: 'c', _decryptedName: 'Charlie' }), 649 + ]; 650 + const keys: Record<string, string> = { a: 'key-a', b: 'key-b', c: 'key-c' }; 651 + 652 + it('returns docs in recency order', () => { 653 + const result = getRecentDocs(['b', 'a', 'c'], allDocs, keys); 654 + expect(result.map(d => d.id)).toEqual(['b', 'a', 'c']); 655 + }); 656 + 657 + it('filters out docs without keys', () => { 658 + const partialKeys = { a: 'key-a' }; 659 + const result = getRecentDocs(['a', 'b', 'c'], allDocs, partialKeys); 660 + expect(result.map(d => d.id)).toEqual(['a']); 661 + }); 662 + 663 + it('filters out non-existent docs', () => { 664 + const result = getRecentDocs(['a', 'missing', 'b'], allDocs, keys); 665 + expect(result.map(d => d.id)).toEqual(['a', 'b']); 666 + }); 667 + 668 + it('limits to displayCount (default 5)', () => { 669 + const manyDocs = Array.from({ length: 10 }, (_, i) => makeDoc({ id: `d${i}` })); 670 + const manyKeys = Object.fromEntries(manyDocs.map(d => [d.id, 'k'])); 671 + const ids = manyDocs.map(d => d.id); 672 + const result = getRecentDocs(ids, manyDocs, manyKeys); 673 + expect(result).toHaveLength(5); 674 + }); 675 + 676 + it('limits to custom displayCount', () => { 677 + const result = getRecentDocs(['a', 'b', 'c'], allDocs, keys, 2); 678 + expect(result).toHaveLength(2); 679 + }); 680 + 681 + it('returns empty for empty recentIds', () => { 682 + expect(getRecentDocs([], allDocs, keys)).toEqual([]); 683 + }); 684 + 685 + it('returns empty when no keys match', () => { 686 + expect(getRecentDocs(['a', 'b'], allDocs, {})).toEqual([]); 687 + }); 688 + });