/** * scaffold-component — offline read/write behaviour split (PWA v1). * * 'read' → mutation controls disabled offline (useOnlineStatus) + stale hint, * submit blocked, offline.* i18n floor in all 4 locales. * 'write' → controls stay LIVE (outbox capture — DEV-PWA-010 forbids * disabling), OutboxStatusChip in list/detail headers, outbox.* keys. * 'none' → zero trace of any of it (byte-level regression handled by the * existing generate.test.ts suite staying green). */ import { describe, it, expect } from 'vitest' import { generate } from '../generate.js' import { validate } from '../validate.js' import type { ScaffoldComponentInput } from '../types.js' function fixture(overrides: Partial = {}): ScaffoldComponentInput { return { module: 'hrm', appCode: 'client', entity: 'Employee', section: 'employees', views: ['list', 'detail', 'form'], fields: [ { name: 'firstName', type: 'string', required: true }, { name: 'lastName', type: 'string', required: true }, ], projectPath: '/web', ...overrides, } } function pages(spec: ScaffoldComponentInput): Record<'list' | 'detail' | 'form', string> { const files = generate(spec) return { list: files.find((f) => f.path.endsWith('EmployeesListPage.tsx'))!.content, detail: files.find((f) => f.path.endsWith('EmployeeDetailPage.tsx'))!.content, form: files.find((f) => f.path.endsWith('EmployeeFormPage.tsx'))!.content, } } describe('scaffold-component — offline READ degradation', () => { const spec = fixture({ pwa: { support: 'adapted', offline: 'read' } }) it('every page imports useOnlineStatus and declares isOnline', () => { for (const content of Object.values(pages(spec))) { // The list page also pulls the mobile kit into the same import. expect(content).toMatch(/import \{ Slot, useOnlineStatus[^}]*\} from '@atlashub\/smartstack'/) expect(content).toContain('const isOnline = useOnlineStatus()') } }) it('list: create + row edit/delete are disabled offline with the tooltip', () => { const { list } = pages(spec) expect(list.match(/disabled=\{!isOnline\}/g)!.length).toBeGreaterThanOrEqual(3) expect(list).toContain("t('employee.offline.actionUnavailable')") expect(list).toContain("t('employee.offline.staleData')") }) it('detail: edit + delete disabled offline', () => { const { detail } = pages(spec) expect(detail.match(/disabled=\{!isOnline\}/g)!.length).toBe(2) }) it('form: submit blocked offline + banner; cancel stays enabled', () => { const { form } = pages(spec) // Unified fiche: the FormPage is the DIRECT create surface (the dirty gate // lives on the DetailPage fiche); the expression stays INLINE with // !isOnline as the last term — DEV-PWA-006 matches /disabled=\{[^}]*!isOnline\}/. expect(form).toContain('disabled={isPending || !isOnline}') expect(form).toContain("t('employee.offline.formUnavailable')") // The cancel button (navigation) must not be gated on isOnline. The // direct create form keeps the plain navigate(-1) (the dirty-guarded // confirm lives on the unified fiche). expect(form).toMatch(/onClick=\{\(\) => navigate\(-1\)\}\n\s+disabled=\{isPending\}/) }) it('emits the offline.* i18n floor in all 4 locales (no outbox.* keys)', () => { const files = generate(spec) for (const locale of ['fr', 'en', 'it', 'de']) { const bundle = files.find((f) => f.path.includes(`/i18n/locales/${locale}/`))! const json = JSON.parse(bundle.content) as Record> expect(json.employee).toHaveProperty('offline') expect(json.employee).not.toHaveProperty('outbox') } }) it('never mounts the OutboxStatusChip in read mode', () => { for (const content of Object.values(pages(spec))) { expect(content).not.toContain('OutboxStatusChip') } }) }) describe('scaffold-component — offline WRITE (outbox)', () => { const spec = fixture({ pwa: { support: 'adapted', offline: 'write' } }) it('mutations are NEVER disabled on isOnline (DEV-PWA-010)', () => { for (const content of Object.values(pages(spec))) { expect(content).not.toContain('disabled={!isOnline}') expect(content).not.toContain('useOnlineStatus') } }) it('list + detail mount the OutboxStatusChip with the componentKey-root resource', () => { const { list, detail, form } = pages(spec) for (const content of [list, detail]) { expect(content).toContain("import { OutboxStatusChip } from '@/components/pwa/OutboxStatusChip'") expect(content).toContain('resourceKey="client.hrm.employees"') expect(content).toContain("t('employee.outbox.pending')") } expect(form).not.toContain('OutboxStatusChip') }) it('emits the outbox.* i18n floor in all 4 locales with real translations', () => { const files = generate(spec) const seen = new Set() for (const locale of ['fr', 'en', 'it', 'de']) { const bundle = files.find((f) => f.path.includes(`/i18n/locales/${locale}/`))! const json = JSON.parse(bundle.content) as Record>> const outbox = json.employee.outbox expect(Object.keys(outbox).sort()).toEqual(['conflict', 'failed', 'pending']) seen.add(outbox.pending) } // Real per-locale translations, not one string copied 4 times (DEV-UI-029 spirit). expect(seen.size).toBeGreaterThan(1) }) }) describe('scaffold-component — mobile kit on the LIST page', () => { it('adds MobileEmptyState + a viewport-gated MobileFab when support is adapted', () => { const { list } = pages(fixture({ pwa: { support: 'adapted' } })) expect(list).toContain( "import { Slot, MobileEmptyState, MobileFab, useViewportMode } from '@atlashub/smartstack'", ) expect(list).toContain('const viewportMode = useViewportMode()') // Empty state replaces the table's plain empty row, reusing list.empty // (no new i18n key, so the 4-locale floor is unchanged). expect(list).toContain("") expect(list).toContain('filtered.length === 0') // The FAB is position:fixed — never rendered on desktop. expect(list).toContain("{viewportMode === 'mobile' && (") expect(list).toContain(' { const { list } = pages(fixture({ pwa: { support: 'adapted' } })) const fabAt = list.indexOf('', fabAt) expect(guardAt).toBeGreaterThan(-1) expect(fabAt - guardAt).toBeLessThan(120) }) it('hides the FAB offline on a READ page (MobileFab has no disabled prop)', () => { const { list } = pages(fixture({ pwa: { support: 'adapted', offline: 'read' } })) expect(list).toContain("{viewportMode === 'mobile' && isOnline && (") }) it('keeps the FAB live on a WRITE page — DEV-PWA-010 forbids degrading writes', () => { const { list } = pages(fixture({ pwa: { support: 'adapted', offline: 'write' } })) expect(list).toContain("{viewportMode === 'mobile' && (") expect(list).not.toContain('isOnline') }) it('emits nothing on a desktop-only page — the mobile shell never resolves it', () => { const { list, detail, form } = pages(fixture({ pwa: { support: 'desktop-only' } })) for (const content of [list, detail, form]) { expect(content).not.toContain('MobileFab') expect(content).not.toContain('MobileEmptyState') expect(content).not.toContain('useViewportMode') } }) it('the kit stays OFF the detail and form pages (list-only affordances)', () => { const { detail, form } = pages(fixture({ pwa: { support: 'adapted' } })) for (const content of [detail, form]) { expect(content).not.toContain('MobileFab') expect(content).not.toContain('useViewportMode') } }) it('output is byte-identical to the pre-PWA generator when pwa is absent', () => { const withoutPwa = pages(fixture()) const desktopOnly = pages(fixture({ pwa: { support: 'desktop-only' } })) expect(desktopOnly.list).toBe(withoutPwa.list) expect(desktopOnly.detail).toBe(withoutPwa.detail) expect(desktopOnly.form).toBe(withoutPwa.form) }) }) describe('scaffold-component — versioned entity (rowversion echo)', () => { it('form loads rowVersion from the detail DTO and echoes it in the update payload', () => { const { form } = pages(fixture({ versioned: true })) expect(form).toContain('const [rowVersion, setRowVersion] = useState(undefined)') expect(form).toContain("setRowVersion((existing as { rowVersion?: string }).rowVersion)") expect(form).toContain('await updateMutation.mutateAsync({ id, data: { ...formData, rowVersion } })') }) it('non-versioned form keeps the plain payload', () => { const { form } = pages(fixture()) expect(form).not.toContain('rowVersion') expect(form).toContain('await updateMutation.mutateAsync({ id, data: formData })') }) }) describe('scaffold-component — pwa validation gates', () => { it("rejects support 'full'", () => { const res = validate(fixture({ pwa: { support: 'full', offline: 'read' } })) expect(res.valid).toBe(false) expect(res.errors.join('\n')).toMatch(/'adapted'/) }) it('pageSpec.pwa wins over the top-level mirror', () => { const spec = fixture({ pwa: { support: 'adapted' }, pageSpec: { screenCode: 'SCR-1', module: 'hrm', appCode: 'client', section: 'employees', entity: 'Employee', view: 'list', filePath: 'src/pages/client/hrm/employees/EmployeesListPage.tsx', permission: 'hrm.employees.read', actions: [], i18nKeys: { fr: {}, en: {}, it: {}, de: {} }, specHash: 'x', needsRefinement: false, pwa: { support: 'full' }, } as never, }) expect(validate(spec).valid).toBe(false) }) it('warns on offline over a form-only spec', () => { const res = validate(fixture({ views: ['form'], pwa: { support: 'adapted', offline: 'read' } })) expect(res.valid).toBe(true) expect(res.warnings.join('\n')).toMatch(/form-only/) }) })