|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * objectstack#27091 — a doc a SIBLING book places is not this book's orphan. |
| 11 | + * |
| 12 | + * ADR-0046 §6.4 rolls up only the docs "left unplaced". HotCRM ships two books |
| 13 | + * over one package (hotcrm#2327): `crm_manual` and `crm_admin_manual`, the |
| 14 | + * latter gated by a permission set. Every doc is claimed by exactly one book, |
| 15 | + * yet each book's sidebar listed the other's pages under a trailing |
| 16 | + * Uncategorized group, because "claimed" was judged against the book's own |
| 17 | + * groups only. The framework's `resolveBookTree` judges against every book |
| 18 | + * since objectstack#27091; this port follows it, and the docs pages pass the |
| 19 | + * AUTHORED books. |
| 20 | + * |
| 21 | + * Each resolver case is checked against the spec's resolver over the same |
| 22 | + * docs and books, the answer `GET /meta/book/:name/tree` gives. |
| 23 | + */ |
| 24 | + |
| 25 | +import { describe, it, expect, vi, afterEach } from 'vitest'; |
| 26 | +import { render, screen, cleanup } from '@testing-library/react'; |
| 27 | +import { MemoryRouter, Routes, Route } from 'react-router-dom'; |
| 28 | +import { resolveBookTree as specResolveBookTree } from '@objectstack/spec/system'; |
| 29 | + |
| 30 | +const { ADAPTER } = vi.hoisted(() => { |
| 31 | + const ADAPTER = { |
| 32 | + getClient: () => ({ |
| 33 | + meta: { |
| 34 | + getItems: async () => [], |
| 35 | + getItem: async (_type: string, name: string) => ({ item: { name, content: `# ${name}` } }), |
| 36 | + }, |
| 37 | + }), |
| 38 | + }; |
| 39 | + return { ADAPTER }; |
| 40 | +}); |
| 41 | + |
| 42 | +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ |
| 43 | + ...(await importOriginal<Record<string, unknown>>()), |
| 44 | + useAdapter: () => ADAPTER, |
| 45 | +})); |
| 46 | + |
| 47 | +vi.mock('@object-ui/i18n', async (importOriginal) => ({ |
| 48 | + ...(await importOriginal<Record<string, unknown>>()), |
| 49 | + useObjectTranslation: () => ({ t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k }), |
| 50 | +})); |
| 51 | + |
| 52 | +// Imported AFTER the mocks so the page picks up the mocked modules. |
| 53 | +import DocPage from './DocPage'; |
| 54 | +import { BookDataContext } from './use-book-data'; |
| 55 | +import { buildPortalBooks, resolveBookTree, type Book, type ResolvedBook, type ResolverDoc } from './book-nav'; |
| 56 | + |
| 57 | +const docs: ResolverDoc[] = [ |
| 58 | + { name: 'crm_overview', label: 'Overview', packageId: 'crm' }, |
| 59 | + { name: 'crm_sales_leads', label: 'Leads', packageId: 'crm' }, |
| 60 | + { name: 'crm_admin_access', label: 'Access control', packageId: 'crm' }, |
| 61 | + { name: 'crm_admin_automation', label: 'Automation', packageId: 'crm' }, |
| 62 | +]; |
| 63 | +const userManual: Book = { |
| 64 | + name: 'crm_manual', |
| 65 | + label: 'CRM Manual', |
| 66 | + packageId: 'crm', |
| 67 | + groups: [ |
| 68 | + { key: 'start', label: 'Start', include: 'crm_overview' }, |
| 69 | + { key: 'sales', label: 'Sales', include: 'crm_sales_*' }, |
| 70 | + ], |
| 71 | +}; |
| 72 | +const adminManual: Book = { |
| 73 | + name: 'crm_admin_manual', |
| 74 | + label: 'CRM Admin Manual', |
| 75 | + packageId: 'crm', |
| 76 | + groups: [{ key: 'admin', label: 'Admin', include: 'crm_admin_*' }], |
| 77 | +}; |
| 78 | +const authored = [userManual, adminManual]; |
| 79 | + |
| 80 | +const keysAndDocs = (r: { groups: { key: string; entries: { doc?: string }[] }[] }) => |
| 81 | + Object.fromEntries(r.groups.map((g) => [g.key, g.entries.map((e) => e.doc)])); |
| 82 | + |
| 83 | +/** What `GET /meta/book/:name/tree` answers: the spec resolver, judged by every book. */ |
| 84 | +const specBook = (b: Book) => ({ name: b.name, label: b.label, groups: b.groups ?? [], packageId: b.packageId }); |
| 85 | +const endpointTree = (b: Book, all: ResolverDoc[]) => |
| 86 | + specResolveBookTree(specBook(b), all, b.packageId, authored.map(specBook)); |
| 87 | + |
| 88 | +afterEach(() => { |
| 89 | + cleanup(); |
| 90 | + vi.restoreAllMocks(); |
| 91 | +}); |
| 92 | + |
| 93 | +describe('objectstack#27091 — the port: a sibling book\'s pages are not Uncategorized', () => { |
| 94 | + it('HotCRM\'s two books: neither tree has an Uncategorized group, and the spec agrees', () => { |
| 95 | + const user: ResolvedBook = resolveBookTree(userManual, docs, undefined, authored); |
| 96 | + expect(keysAndDocs(user)).toEqual({ start: ['crm_overview'], sales: ['crm_sales_leads'] }); |
| 97 | + expect(keysAndDocs(resolveBookTree(adminManual, docs, undefined, authored))).toEqual({ |
| 98 | + admin: ['crm_admin_access', 'crm_admin_automation'], |
| 99 | + }); |
| 100 | + for (const b of authored) { |
| 101 | + expect(keysAndDocs(resolveBookTree(b, docs, undefined, authored))).toEqual(keysAndDocs(endpointTree(b, docs))); |
| 102 | + } |
| 103 | + }); |
| 104 | + |
| 105 | + it('a doc NO book claims still rolls up under Uncategorized', () => { |
| 106 | + const withStray = [...docs, { name: 'crm_stray', label: 'Stray', packageId: 'crm' }]; |
| 107 | + expect(keysAndDocs(resolveBookTree(userManual, withStray, undefined, authored)).uncategorized).toEqual(['crm_stray']); |
| 108 | + }); |
| 109 | + |
| 110 | + it('CONTROL: without `books` the tree is unchanged, the sibling\'s pages still in Uncategorized', () => { |
| 111 | + expect(keysAndDocs(resolveBookTree(userManual, docs)).uncategorized).toEqual([ |
| 112 | + 'crm_admin_access', |
| 113 | + 'crm_admin_automation', |
| 114 | + ]); |
| 115 | + }); |
| 116 | +}); |
| 117 | + |
| 118 | +describe('objectstack#27091 — the reader\'s sidebar for crm_manual', () => { |
| 119 | + it('lists the book\'s own pages and no Uncategorized group holding the admin pages', async () => { |
| 120 | + const value = { authoredBooks: authored, books: buildPortalBooks(authored, docs), docs, state: 'ready' as const }; |
| 121 | + render( |
| 122 | + <BookDataContext.Provider value={value}> |
| 123 | + <MemoryRouter initialEntries={['/docs/crm_manual/crm_overview']}> |
| 124 | + <Routes> |
| 125 | + <Route path="/docs/:slug/:name" element={<DocPage />} /> |
| 126 | + </Routes> |
| 127 | + </MemoryRouter> |
| 128 | + </BookDataContext.Provider>, |
| 129 | + ); |
| 130 | + expect((await screen.findAllByRole('link', { name: 'Leads' })).length).toBeGreaterThan(0); |
| 131 | + expect(screen.queryByText('Uncategorized')).toBeNull(); |
| 132 | + expect(screen.queryByRole('link', { name: 'Access control' })).toBeNull(); |
| 133 | + expect(screen.queryByRole('link', { name: 'Automation' })).toBeNull(); |
| 134 | + }); |
| 135 | +}); |
0 commit comments