import { describe, it, expect } from 'vitest' import { PageRelatedTabSchema, RELATED_TAB_DISPLAY_MODES, RELATED_TAB_PLACEMENTS, normalizePageRelatedTab, placementForDisplayMode, relatedPluralOf, relatedRouteFamilyOf, relatedTabFloorKeys, relatedTabPlacementOf, relatedAppOf, isCrossAppTab, isCrossSurfaceTab, requiresAvailabilityGuard, relatedExtensionsId, fkQueryParam, fkFilterFields, isFkFilterField, parseRelatedTabs, type PageRelatedTab, } from '../page-spec-related-tabs.js' import { PageSpecMinSchema } from '../../development/frontend/component/cli/scaffold-component/types.js' const CANONICAL = { key: 'invoices', displayMode: 'table', relatedEntity: 'Invoice', relationFk: 'clientId', relatedModule: 'crm', relatedSection: 'invoices', targetScreen: 'SCR-CRM-INVOICES-LIST-001', permission: 'crm.invoices.read', } describe('PageRelatedTabSchema — parse + defaults', () => { it('parses a canonical tab and fills the defaults', () => { const tab = PageRelatedTabSchema.parse(CANONICAL) as PageRelatedTab expect(tab.displayMode).toBe('table') // NO implicit withCreate default any more: absent = "resolve from reality" // (the generator emits the button only when a create form is resolvable). expect(tab.withCreate).toBeUndefined() expect(tab.withRowOpen).toBeUndefined() expect(tab.crossModule).toBe(false) }) it('accepts the sub-view routing fields (relatedRouteFamily, createPermission, withRowOpen)', () => { const tab = PageRelatedTabSchema.parse({ ...CANONICAL, relatedSection: 'list', relatedRouteFamily: 'work-packages', createPermission: 'portfolio.list.work-package.create', withCreate: true, withRowOpen: false, }) as PageRelatedTab expect(tab.relatedRouteFamily).toBe('work-packages') expect(tab.createPermission).toBe('portfolio.list.work-package.create') expect(tab.withRowOpen).toBe(false) }) it('rejects a non-kebab relatedRouteFamily and a malformed createPermission', () => { expect( PageRelatedTabSchema.safeParse({ ...CANONICAL, relatedRouteFamily: 'workPackages' }).success, ).toBe(false) expect( PageRelatedTabSchema.safeParse({ ...CANONICAL, createPermission: 'create' }).success, ).toBe(false) }) it('defaults displayMode to table when omitted', () => { const { displayMode: _drop, ...rest } = CANONICAL const tab = PageRelatedTabSchema.parse(rest) as PageRelatedTab expect(tab.displayMode).toBe('table') }) it('accepts every v1 display mode and nothing else', () => { expect([...RELATED_TAB_DISPLAY_MODES]).toEqual(['table', 'cards', 'summary']) for (const mode of RELATED_TAB_DISPLAY_MODES) { expect(PageRelatedTabSchema.safeParse({ ...CANONICAL, displayMode: mode }).success).toBe(true) } // 'report' is explicitly deferred — no rendering surface exists. expect(PageRelatedTabSchema.safeParse({ ...CANONICAL, displayMode: 'report' }).success).toBe(false) }) it('accepts the BA aliases relationship → relationFk and screenTarget → targetScreen', () => { const { relationFk: _fk, targetScreen: _ts, ...rest } = CANONICAL const tab = PageRelatedTabSchema.parse({ ...rest, relationship: 'clientId', screenTarget: 'SCR-CRM-INVOICES-LIST-001', }) as PageRelatedTab expect(tab.relationFk).toBe('clientId') expect(tab.targetScreen).toBe('SCR-CRM-INVOICES-LIST-001') }) it('canonical names win over aliases when both are present', () => { const tab = PageRelatedTabSchema.parse({ ...CANONICAL, relationship: 'otherId', screenTarget: 'SCR-OTHER-LIST-001', }) as PageRelatedTab expect(tab.relationFk).toBe('clientId') expect(tab.targetScreen).toBe('SCR-CRM-INVOICES-LIST-001') }) it('rejects malformed entries with a precise path', () => { const badCases: Array<[string, Record]> = [ ['key not lowercase', { ...CANONICAL, key: 'Invoices' }], ['relatedEntity not PascalCase', { ...CANONICAL, relatedEntity: 'invoice' }], ['relationFk kebab-case', { ...CANONICAL, relationFk: 'client-id' }], ['targetScreen not SCR-…', { ...CANONICAL, targetScreen: 'CRM-INVOICES' }], ['permission single segment', { ...CANONICAL, permission: 'read' }], ['missing relatedModule', (({ relatedModule: _m, ...rest }) => rest)(CANONICAL)], ] for (const [label, entry] of badCases) { expect(PageRelatedTabSchema.safeParse(entry).success, label).toBe(false) } }) it('accepts a 4-segment app-scoped permission', () => { const ok = PageRelatedTabSchema.safeParse({ ...CANONICAL, permission: 'hr.hr.leave-requests.read', }) expect(ok.success).toBe(true) }) }) describe('placement — schema + resolver', () => { const SUMMARY = { ...CANONICAL, key: 'insurances', displayMode: 'summary' } it('accepts summary + band and summary + explicit tab (the opt-back)', () => { expect(PageRelatedTabSchema.safeParse({ ...SUMMARY, placement: 'band' }).success).toBe(true) expect(PageRelatedTabSchema.safeParse({ ...SUMMARY, placement: 'tab' }).success).toBe(true) }) it("rejects placement 'band' on a non-summary tab, with the issue on placement", () => { for (const displayMode of ['table', 'cards']) { const res = PageRelatedTabSchema.safeParse({ ...CANONICAL, displayMode, placement: 'band' }) expect(res.success, displayMode).toBe(false) if (!res.success) { expect(res.error.issues.some(i => i.path.join('.') === 'placement')).toBe(true) } } // displayMode omitted defaults to 'table' — band must still be rejected. const { displayMode: _drop, ...rest } = CANONICAL expect(PageRelatedTabSchema.safeParse({ ...rest, placement: 'band' }).success).toBe(false) }) it('rejects an unknown placement value', () => { expect(PageRelatedTabSchema.safeParse({ ...SUMMARY, placement: 'sidebar' }).success).toBe(false) expect([...RELATED_TAB_PLACEMENTS]).toEqual(['tab', 'band']) }) it('derives the default placement: summary → band, table/cards → tab', () => { const summary = PageRelatedTabSchema.parse(SUMMARY) as PageRelatedTab expect(relatedTabPlacementOf(summary)).toBe('band') const table = PageRelatedTabSchema.parse(CANONICAL) as PageRelatedTab expect(relatedTabPlacementOf(table)).toBe('tab') const cards = PageRelatedTabSchema.parse({ ...CANONICAL, displayMode: 'cards' }) as PageRelatedTab expect(relatedTabPlacementOf(cards)).toBe('tab') }) it('an explicit placement always wins over the derived default', () => { const optBack = PageRelatedTabSchema.parse({ ...SUMMARY, placement: 'tab' }) as PageRelatedTab expect(relatedTabPlacementOf(optBack)).toBe('tab') // Low-level half (screen.md bullet vocabulary — raw strings). expect(placementForDisplayMode('summary')).toBe('band') expect(placementForDisplayMode('summary', 'tab')).toBe('tab') expect(placementForDisplayMode('table')).toBe('tab') }) }) describe('normalizePageRelatedTab', () => { it('fills labelKey and permission from the tab identity', () => { const { permission: _p, ...rest } = CANONICAL const tab = normalizePageRelatedTab(PageRelatedTabSchema.parse(rest) as PageRelatedTab) expect(tab.labelKey).toBe('detail.related.invoices.label') expect(tab.permission).toBe('crm.invoices.read') }) it('never overwrites explicit values', () => { const tab = normalizePageRelatedTab( PageRelatedTabSchema.parse({ ...CANONICAL, labelKey: 'custom.label', permission: 'crm.billing.read', }) as PageRelatedTab, ) expect(tab.labelKey).toBe('custom.label') expect(tab.permission).toBe('crm.billing.read') }) }) describe('relatedPluralOf', () => { it('prefers the explicit relatedPlural', () => { const tab = PageRelatedTabSchema.parse({ ...CANONICAL, relatedPlural: 'Factures', }) as PageRelatedTab expect(relatedPluralOf(tab)).toBe('Factures') }) it('falls back to the english pluralizer', () => { expect(relatedPluralOf(PageRelatedTabSchema.parse(CANONICAL) as PageRelatedTab)).toBe('Invoices') expect( relatedPluralOf( PageRelatedTabSchema.parse({ ...CANONICAL, relatedEntity: 'Company' }) as PageRelatedTab, ), ).toBe('Companies') }) }) describe('relatedRouteFamilyOf', () => { it('prefers relatedRouteFamily and falls back to relatedSection', () => { const legacy = PageRelatedTabSchema.parse(CANONICAL) as PageRelatedTab expect(relatedRouteFamilyOf(legacy)).toBe('invoices') const subView = PageRelatedTabSchema.parse({ ...CANONICAL, relatedSection: 'list', relatedRouteFamily: 'work-packages', }) as PageRelatedTab expect(relatedRouteFamilyOf(subView)).toBe('work-packages') }) }) describe('fk filter detection (SSOT for backend + api-client params)', () => { it('keeps Guid Id-suffixed business FKs, camelCase-normalised', () => { expect( fkFilterFields([ { name: 'ClientId', type: 'Guid' }, { name: 'contactId', type: 'guid' }, { name: 'label', type: 'string' }, ]), ).toEqual(['clientId', 'contactId']) }) it('excludes system columns and non-Guid Id-suffixed fields', () => { expect(isFkFilterField({ name: 'Id', type: 'Guid' })).toBe(false) expect(isFkFilterField({ name: 'TenantId', type: 'Guid' })).toBe(false) expect(isFkFilterField({ name: 'tenantId', type: 'guid' })).toBe(false) expect(isFkFilterField({ name: 'externalId', type: 'string' })).toBe(false) expect(isFkFilterField({ name: 'CreatedBy', type: 'Guid' })).toBe(false) }) it('fkQueryParam is the identity — wire name === relationFk', () => { expect(fkQueryParam('clientId')).toBe('clientId') }) }) describe('relatedTabFloorKeys', () => { it('lists the full i18n floor family for one tab', () => { expect(relatedTabFloorKeys('invoices')).toEqual([ 'detail.related.invoices.label', 'detail.related.invoices.empty', 'detail.related.invoices.loading', 'detail.related.invoices.error', 'detail.related.invoices.create', 'detail.related.invoices.viewAll', 'detail.related.invoices.count', 'detail.related.invoices.previous', 'detail.related.invoices.next', ]) }) }) describe('parseRelatedTabs', () => { it('returns normalised tabs and keeps invalid entries in rejected with Zod issues', () => { const { tabs, rejected } = parseRelatedTabs([ CANONICAL, { ...CANONICAL, key: 'contacts', relatedEntity: 'contact' }, // invalid: not PascalCase ]) expect(tabs).toHaveLength(1) expect(tabs[0].labelKey).toBe('detail.related.invoices.label') expect(rejected).toHaveLength(1) expect(rejected[0].index).toBe(1) expect(rejected[0].issues.join('\n')).toMatch(/relatedEntity/) }) it('tolerates a missing/non-array field (legacy pagespecs)', () => { expect(parseRelatedTabs(undefined)).toEqual({ tabs: [], rejected: [] }) expect(parseRelatedTabs('nope')).toEqual({ tabs: [], rejected: [] }) }) }) describe('contract — pagespec round-trip through scaffold-component PageSpecMinSchema', () => { const pageSpec = { screenCode: 'SCR-CRM-CLIENTS-DETAIL-001', module: 'crm', appCode: 'TestV2', section: 'clients', entity: 'Client', view: 'detail', filePath: 'src/pages/crm/clients/ClientDetailPage.tsx', permission: 'crm.clients.read', actions: [], relatedTabs: [ CANONICAL, { key: 'billing-addresses', displayMode: 'cards', relatedEntity: 'BillingAddress', // BA vocabulary on purpose — the mirror schema must not reject aliases. relationship: 'clientId', screenTarget: 'SCR-CRM-ADDRESSES-LIST-001', relatedModule: 'crm', relatedSection: 'addresses', }, { key: 'insurances', displayMode: 'summary', placement: 'band', relatedEntity: 'Insurance', relationFk: 'clientId', relatedModule: 'crm', relatedSection: 'insurances', }, ], i18nKeys: { fr: {}, en: {}, it: {}, de: {} }, specHash: 'abc123', } it('PageSpecMinSchema accepts a pagespec carrying relatedTabs (canonical + alias forms)', () => { const parsed = PageSpecMinSchema.parse(pageSpec) const { tabs, rejected } = parseRelatedTabs((parsed as Record).relatedTabs) expect(rejected).toEqual([]) expect(tabs.map(t => t.key)).toEqual(['invoices', 'billing-addresses', 'insurances']) expect(tabs[1].relationFk).toBe('clientId') expect(tabs[1].permission).toBe('crm.addresses.read') expect(tabs[1].displayMode).toBe('cards') // placement rides through the mirror schema (passthrough) untouched. expect(tabs[2].placement).toBe('band') expect(relatedTabPlacementOf(tabs[2])).toBe('band') }) }) describe('cross-application related tabs', () => { const base = { key: 'invoices', relatedEntity: 'Invoice', relationFk: 'clientId', relatedModule: 'factures', relatedSection: 'list', } const parse = (over: Record = {}) => PageRelatedTabSchema.parse({ ...base, ...over }) describe('relatedApp', () => { it('accepts a kebab application code', () => { expect(parse({ relatedApp: 'facturation' }).relatedApp).toBe('facturation') expect(parse({ relatedApp: 'gestion-locative' }).relatedApp).toBe('gestion-locative') }) it('rejects a non-kebab application code', () => { expect(() => parse({ relatedApp: 'FACTURATION' })).toThrow() expect(() => parse({ relatedApp: 'facturation_v2' })).toThrow() }) it('is optional — a legacy tab keeps its exact meaning', () => { expect(parse().relatedApp).toBeUndefined() }) }) describe('relatedAppOf', () => { it('falls back to the page own application when absent', () => { expect(relatedAppOf(parse(), 'crm')).toBe('crm') }) it('honours an explicit relatedApp', () => { expect(relatedAppOf(parse({ relatedApp: 'facturation' }), 'crm')).toBe('facturation') }) it('lower-cases — the BA tree names applications in UPPERCASE', () => { expect(relatedAppOf(parse(), 'CRM')).toBe('crm') }) }) describe('isCrossAppTab / isCrossSurfaceTab', () => { it('a tab with no relatedApp never crosses applications', () => { expect(isCrossAppTab(parse(), 'crm')).toBe(false) }) it('detects the other application, case-insensitively', () => { expect(isCrossAppTab(parse({ relatedApp: 'facturation' }), 'crm')).toBe(true) expect(isCrossAppTab(parse({ relatedApp: 'crm' }), 'CRM')).toBe(false) }) it('a same-app OTHER module still leaves the page own surface', () => { expect(isCrossSurfaceTab(parse(), 'crm', 'clients')).toBe(true) }) it('a same-app same-module tab stays on its own surface', () => { expect(isCrossSurfaceTab(parse({ relatedModule: 'clients' }), 'crm', 'clients')).toBe(false) expect(isCrossSurfaceTab(parse({ relatedModule: 'CLIENTS' }), 'CRM', 'clients')).toBe(false) }) }) describe('requiresAvailabilityGuard', () => { it('guards a cross-application tab', () => { expect(requiresAvailabilityGuard(parse({ relatedApp: 'facturation' }), 'crm', 'clients')).toBe(true) }) it('guards a cross-module tab of the same application', () => { expect(requiresAvailabilityGuard(parse(), 'crm', 'clients')).toBe(true) }) it('never guards a tab pointing at its own module — the page could not be reached otherwise', () => { expect(requiresAvailabilityGuard(parse({ relatedModule: 'clients' }), 'crm', 'clients')).toBe(false) }) it('honours the explicit opt-out', () => { expect( requiresAvailabilityGuard(parse({ relatedApp: 'facturation', availabilityCheck: false }), 'crm', 'clients') ).toBe(false) }) it('an explicit true on an own-surface tab does NOT force a guard — there is nothing to guard', () => { expect( requiresAvailabilityGuard(parse({ relatedModule: 'clients', availabilityCheck: true }), 'crm', 'clients') ).toBe(false) }) }) describe('relatedExtensionsId', () => { it('keys the routes file by the TARGET application, not the page one', () => { expect(relatedExtensionsId(parse({ relatedApp: 'facturation' }), 'crm')).toBe('facturation-factures') }) it('falls back to the page application for a same-app tab', () => { expect(relatedExtensionsId(parse(), 'CRM')).toBe('crm-factures') }) }) })