|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect } from 'vitest'; |
| 4 | +import { PageTabsProps } from '@objectstack/spec/ui'; |
| 5 | + |
| 6 | +import * as pages from '../src/ui/pages/index.js'; |
| 7 | +import { ProjectDetailPage } from '../src/ui/pages/index.js'; |
| 8 | + |
| 9 | +/** |
| 10 | + * Dogfood gate for the project-detail page's tab strip (objectstack#5776). |
| 11 | + * |
| 12 | + * `page:tabs` items carry a **stable `?tab=` URL token**, and its one spelling |
| 13 | + * is `value`: that is the key `PageTabsProps.items[]` declares (#5775) and the |
| 14 | + * only one objectui's tabs renderer reads — `containers.tsx` takes a non-empty |
| 15 | + * string `it.value` and otherwise derives the token from the item's index. |
| 16 | + * This page authored `key` instead — a spelling neither side knows — so both |
| 17 | + * tabs silently fell back to the index-derived `tab-<i>`: the page renders, |
| 18 | + * and a deep link comes back to whichever tab happens to sit at that index. |
| 19 | + * #5068's `component-props-unknown-key` gate reported it as exactly that, twice. |
| 20 | + * |
| 21 | + * The assertions pin both halves, so neither can regress in silence: |
| 22 | + * - the tokens are authored under the declared key and survive the schema's |
| 23 | + * own parse (an undeclared key would be stripped, not carried); |
| 24 | + * - `key` — and the other near-miss spellings — appear on no tab item in the |
| 25 | + * whole showcase corpus, so a later page cannot re-introduce the shape; |
| 26 | + * - the tokens are semantic and distinct, which is the property the |
| 27 | + * index-derived fallback does not have. |
| 28 | + */ |
| 29 | + |
| 30 | +type TabItem = Record<string, unknown> & { label?: unknown; value?: unknown }; |
| 31 | +type AnyComponent = { |
| 32 | + type?: unknown; |
| 33 | + properties?: Record<string, unknown>; |
| 34 | + [k: string]: unknown; |
| 35 | +}; |
| 36 | + |
| 37 | +/** Every component on a page — regions and slots alike, nested tabs included. */ |
| 38 | +function allComponents(page: Record<string, unknown>): AnyComponent[] { |
| 39 | + const out: AnyComponent[] = []; |
| 40 | + const visit = (node: unknown): void => { |
| 41 | + if (!node || typeof node !== 'object' || Array.isArray(node)) return; |
| 42 | + const component = node as AnyComponent; |
| 43 | + out.push(component); |
| 44 | + const props = component.properties; |
| 45 | + if (!props) return; |
| 46 | + for (const item of Array.isArray(props.items) ? props.items : []) { |
| 47 | + const children = (item as TabItem)?.children; |
| 48 | + for (const child of Array.isArray(children) ? children : []) visit(child); |
| 49 | + } |
| 50 | + for (const child of Array.isArray(props.children) ? props.children : []) visit(child); |
| 51 | + }; |
| 52 | + |
| 53 | + for (const region of (page.regions as { components?: unknown[] }[] | undefined) ?? []) { |
| 54 | + for (const c of region.components ?? []) visit(c); |
| 55 | + } |
| 56 | + for (const slot of Object.values((page.slots as Record<string, unknown>) ?? {})) { |
| 57 | + for (const c of Array.isArray(slot) ? slot : [slot]) visit(c); |
| 58 | + } |
| 59 | + return out; |
| 60 | +} |
| 61 | + |
| 62 | +const tabsComponents = (page: Record<string, unknown>): AnyComponent[] => |
| 63 | + allComponents(page).filter((c) => c.type === 'page:tabs'); |
| 64 | + |
| 65 | +const tabItems = (component: AnyComponent): TabItem[] => |
| 66 | + (Array.isArray(component.properties?.items) ? component.properties!.items : []) as TabItem[]; |
| 67 | + |
| 68 | +/** Every page the showcase exports (the corpus the #5068 gate runs on). */ |
| 69 | +const allPages = (Object.values(pages) as unknown[]).filter( |
| 70 | + (p) => |
| 71 | + !!p && typeof p === 'object' && !Array.isArray(p) && typeof (p as { name?: unknown }).name === 'string', |
| 72 | +) as Record<string, unknown>[]; |
| 73 | + |
| 74 | +describe('Project detail — tab tokens are authored under the declared `value` (#5776)', () => { |
| 75 | + it('gives both tabs a stable `?tab=` token under `value`', () => { |
| 76 | + const [tabs, ...rest] = tabsComponents(ProjectDetailPage as unknown as Record<string, unknown>); |
| 77 | + expect(tabs, 'the project-detail page must carry a page:tabs component').toBeTruthy(); |
| 78 | + expect(rest, 'exactly one tab strip on this page').toHaveLength(0); |
| 79 | + |
| 80 | + const items = tabItems(tabs); |
| 81 | + expect(items).toHaveLength(2); |
| 82 | + expect(items.map((it) => it.value)).toEqual(['details', 'tasks']); |
| 83 | + for (const item of items) { |
| 84 | + expect(item, `tab "${String(item.label)}" must not carry the undeclared \`key\``).not.toHaveProperty('key'); |
| 85 | + } |
| 86 | + }); |
| 87 | + |
| 88 | + it('survives `PageTabsProps` parse with the tokens intact — a declared key is CARRIED', () => { |
| 89 | + // The point of the rename: an undeclared key is dropped by the parse (strip |
| 90 | + // mode) while looking authored in the source. Parsing here proves the token |
| 91 | + // reaches a consumer, which is the whole difference between `key` and |
| 92 | + // `value`. |
| 93 | + const tabs = tabsComponents(ProjectDetailPage as unknown as Record<string, unknown>)[0]!; |
| 94 | + const parsed = PageTabsProps.parse(tabs.properties); |
| 95 | + expect(parsed.items.map((it) => it.value)).toEqual(['details', 'tasks']); |
| 96 | + }); |
| 97 | + |
| 98 | + it('leaves no near-miss token spelling on any tab item in the showcase corpus', () => { |
| 99 | + // The regression this pins is a new page (or a rewrite of this one) reaching |
| 100 | + // for `key` / `id` / `name` again — every one of them renders fine and |
| 101 | + // sets no token. |
| 102 | + for (const page of allPages) { |
| 103 | + for (const tabs of tabsComponents(page)) { |
| 104 | + for (const item of tabItems(tabs)) { |
| 105 | + for (const spelling of ['key', 'id', 'name', 'tabKey', 'slug'] as const) { |
| 106 | + expect( |
| 107 | + item, |
| 108 | + `page "${String(page.name)}" tab "${String(item.label)}" must carry its token as \`value\`, not \`${spelling}\``, |
| 109 | + ).not.toHaveProperty(spelling); |
| 110 | + } |
| 111 | + } |
| 112 | + } |
| 113 | + } |
| 114 | + }); |
| 115 | + |
| 116 | + it('uses semantic, distinct tokens — the property the index fallback lacks', () => { |
| 117 | + const values = tabItems( |
| 118 | + tabsComponents(ProjectDetailPage as unknown as Record<string, unknown>)[0]!, |
| 119 | + ).map((it) => it.value as string); |
| 120 | + expect(new Set(values).size).toBe(values.length); |
| 121 | + for (const value of values) { |
| 122 | + expect(value).toMatch(/^[a-z][a-z0-9_:-]*$/); |
| 123 | + // `tab-<i>` is exactly the derived fallback; authoring it back would make |
| 124 | + // the token as fragile as having none. |
| 125 | + expect(value).not.toMatch(/^tab-\d+$/); |
| 126 | + } |
| 127 | + }); |
| 128 | +}); |
0 commit comments