diff --git a/client/src/components/Planner/PlaceInspector.test.tsx b/client/src/components/Planner/PlaceInspector.test.tsx index c02cfa664..658917f19 100644 --- a/client/src/components/Planner/PlaceInspector.test.tsx +++ b/client/src/components/Planner/PlaceInspector.test.tsx @@ -689,14 +689,21 @@ describe('PlaceInspector', () => { }); it('FE-PLANNER-INSPECTOR-2472: the own e-mail and hours show, the hours ahead of looked-up ones', async () => { - const user = userEvent.setup(); - const week = JSON.stringify(Array.from({ length: 7 }, (_, i) => (i === 6 ? { closed: true } : { closed: false, open: '08:00', close: '12:00' }))); - const p = buildPlace({ id: 302, email: 'hi@bakery.test', opening_hours: week } as any); - render(); - expect(screen.getByRole('link', { name: /hi@bakery\.test/ })).toHaveAttribute('href', 'mailto:hi@bakery.test'); - expect(screen.getByText('Opening Hours')).toBeInTheDocument(); - await user.click(screen.getByRole('button', { expanded: false, name: /08:00|Show/ })); - expect(screen.getByText(/Sunday: Closed/)).toBeInTheDocument(); + vi.useFakeTimers({ toFake: ['Date'] }); + try { + vi.setSystemTime(new Date('2026-06-02T12:00:00Z')); + const user = userEvent.setup(); + const week = JSON.stringify(Array.from({ length: 7 }, (_, i) => (i === 6 ? { closed: true } : { closed: false, open: '08:00', close: '12:00' }))); + const placeWithHours = { id: 302, email: 'hi@bakery.test', opening_hours: week }; + const p = buildPlace(placeWithHours); + render(); + expect(screen.getByRole('link', { name: /hi@bakery\.test/ })).toHaveAttribute('href', 'mailto:hi@bakery.test'); + expect(screen.getByText('Opening Hours')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { expanded: false, name: /08:00|Show/ })); + expect(screen.getByText(/Sunday: Closed/)).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } }); // ── File size display ────────────────────────────────────────────────────── diff --git a/client/src/pages/tripPlanner/b4a-wiring.test.ts b/client/src/pages/tripPlanner/b4a-wiring.test.ts index 2aee4a945..dc51fa5a4 100644 --- a/client/src/pages/tripPlanner/b4a-wiring.test.ts +++ b/client/src/pages/tripPlanner/b4a-wiring.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { readFileSync, readdirSync } from 'node:fs' +import { join, resolve } from 'node:path' import * as ts from 'typescript' import { test, vi } from 'vitest' @@ -11,11 +11,14 @@ vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden const pagePath = 'client/src/pages/TripPlannerPage.tsx' const hookPath = 'client/src/pages/tripPlanner/useTripPlanner.ts' +const pageControllerPath = 'client/src/pages/tripPlanner/useTripPlannerPage.ts' const pageText = readFileSync(resolve(process.cwd(), 'src/pages/TripPlannerPage.tsx'), 'utf8') const hookText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useTripPlanner.ts'), 'utf8') +const pageControllerText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useTripPlannerPage.ts'), 'utf8') const parse = (file: string, text: string) => ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true) const page = parse(pagePath, pageText) const hook = parse(hookPath, hookText) +const pageController = parse(pageControllerPath, pageControllerText) function collect(source: ts.Node, predicate: (node: ts.Node) => boolean): ts.Node[] { const matches: ts.Node[] = [] @@ -27,20 +30,42 @@ function collect(source: ts.Node, predicate: (node: ts.Node) => boolean): ts.Nod return matches } -function initializer(name: string): ts.Expression { - const declaration = collect(hook, node => ts.isVariableDeclaration(node) +function initializer(source: ts.SourceFile, name: string): ts.Expression { + const declaration = collect(source, node => ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name)[0] assert.ok(declaration && ts.isVariableDeclaration(declaration) && declaration.initializer, name) return declaration.initializer } -function jsxTags(source: ts.SourceFile, name: string) { +function callNamed(source: ts.SourceFile, name: string): ts.CallExpression | undefined { + const call = collect(source, node => ts.isCallExpression(node) + && ts.isIdentifier(node.expression) && node.expression.text === name)[0] + return call && ts.isCallExpression(call) ? call : undefined +} + +function propertyValue(object: ts.ObjectLiteralExpression, name: string): ts.Expression | undefined { + const property = object.properties.find(item => ts.isPropertyAssignment(item) + && (ts.isIdentifier(item.name) || ts.isStringLiteral(item.name)) && item.name.text === name) + return property && ts.isPropertyAssignment(property) ? property.initializer : undefined +} + +function mobileSourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return mobileSourceFiles(path) + return /\.tsx?$/.test(entry.name) ? [path] : [] + }) +} + +function jsxTags(source: ts.SourceFile, name: string): (ts.JsxOpeningElement | ts.JsxSelfClosingElement)[] { return collect(source, node => (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && node.tagName.getText(source) === name) + .filter((node): node is ts.JsxOpeningElement | ts.JsxSelfClosingElement => + ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) } test('B4A Core testability debt: verify source without importing the page, hook, config, or database', () => { - for (const source of [page, hook]) { + for (const source of [page, hook, pageController]) { const diagnostics = ts.transpileModule(source.text, { fileName: source.fileName, reportDiagnostics: true, @@ -60,7 +85,7 @@ test('B4A Core testability debt: verify source without importing the page, hook, }) test('B4A Tours tab is readable, desktop-only, and absent with addon OFF', () => { - const tabs = initializer('TRIP_TABS') + const tabs = initializer(hook, 'TRIP_TABS') const branches = collect(tabs, node => ts.isConditionalExpression(node) && node.whenTrue.getText(hook).includes("id: 'tour-planner'")) assert.equal(branches.length, 1) @@ -75,40 +100,103 @@ test('B4A Tours tab is readable, desktop-only, and absent with addon OFF', () => }) test('B4A selectedTour is derived only from explicit place selection and addon state', () => { - const selection = initializer('selectedTour') + const selection = initializer(hook, 'selectedTour') assert.ok(ts.isConditionalExpression(selection)) assert.equal(selection.condition.getText(hook), 'toursEnabled && selectedPlaceId') assert.match(selection.whenTrue.getText(hook), /^tours\.find\(\w+ => \w+\.place_id === selectedPlaceId\) \?\? null$/) assert.equal(selection.whenFalse.kind, ts.SyntaxKind.NullKeyword) }) -test('B4A approved permissions reach every Tours surface and gate map edits', () => { - assert.match(pageText, /const canPlaceEdit = can\('place_edit', trip\)/) - assert.match(pageText, /const canDayEdit = can\('day_edit', trip\)/) - assert.match(pageText, /useTourPlanner\(\{\s*tripId,\s*canEdit: canPlaceEdit,\s*canAssign: canDayEdit,/) +test('B4A desktop page delegates Tours state and permissions to its page hook', () => { + const pageHookImport = collect(page, node => ts.isImportDeclaration(node) + && ts.isStringLiteral(node.moduleSpecifier) + && node.moduleSpecifier.text === './tripPlanner/useTripPlannerPage')[0] + assert.ok(pageHookImport && ts.isImportDeclaration(pageHookImport)) + const importedBindings = pageHookImport.importClause?.namedBindings + assert.ok(importedBindings && ts.isNamedImports(importedBindings) + && importedBindings.elements.some(element => element.name.text === 'useTripPlannerPage')) + + const desktop = collect(page, node => ts.isFunctionDeclaration(node) + && node.name?.text === 'TripPlannerPageDesktop')[0] + assert.ok(desktop) + const pageHookCall = collect(desktop, node => ts.isCallExpression(node) + && ts.isIdentifier(node.expression) && node.expression.text === 'useTripPlannerPage')[0] + assert.ok(pageHookCall) + + assert.ok(callNamed(pageController, 'useTourPlanner')) + const mobileFiles = mobileSourceFiles(resolve(process.cwd(), 'src/mobile')) + for (const file of mobileFiles) { + const mobileSource = parse(file, readFileSync(file, 'utf8')) + const importsDesktopHook = collect(mobileSource, ts.isImportDeclaration).some(node => { + if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) return false + const modulePath = node.moduleSpecifier.text + const namedImport = node.importClause?.namedBindings + return /(?:^|\/)useTripPlannerPage(?:\.tsx?)?$/.test(modulePath) + || (namedImport && ts.isNamedImports(namedImport) + && namedImport.elements.some(element => element.name.text === 'useTripPlannerPage')) + }) + assert.equal(importsDesktopHook, false, file) + } + + const permissions = initializer(pageController, 'canPlaceEdit') + const permissionCalls = collect(pageController, node => ts.isCallExpression(node) + && ts.isIdentifier(node.expression) && node.expression.text === 'can') + for (const action of ['place_edit', 'day_edit']) { + assert.ok(permissionCalls.some(node => ts.isCallExpression(node) + && ts.isStringLiteral(node.arguments[0]) && node.arguments[0].text === action + && ts.isIdentifier(node.arguments[1]) && node.arguments[1].text === 'trip')) + } + assert.ok(ts.isCallExpression(permissions) && ts.isIdentifier(permissions.expression) + && permissions.expression.text === 'can') + + const tourPlannerCall = callNamed(pageController, 'useTourPlanner') + assert.ok(tourPlannerCall && ts.isObjectLiteralExpression(tourPlannerCall.arguments[0])) + const tourPlannerOptions = tourPlannerCall.arguments[0] + assert.equal(propertyValue(tourPlannerOptions, 'canEdit')?.getText(pageController), 'canPlaceEdit') + assert.equal(propertyValue(tourPlannerOptions, 'canAssign')?.getText(pageController), 'canDayEdit') + + const returnedViewModel = collect(pageController, node => ts.isReturnStatement(node) + && !!node.expression && ts.isObjectLiteralExpression(node.expression)) + .find((node): node is ts.ReturnStatement => ts.isReturnStatement(node))?.expression + assert.ok(returnedViewModel && ts.isObjectLiteralExpression(returnedViewModel)) + const returnedPermissions = propertyValue(returnedViewModel, 'permissions') + assert.ok(returnedPermissions && ts.isObjectLiteralExpression(returnedPermissions)) + assert.ok(returnedPermissions.properties.some(property => ts.isShorthandPropertyAssignment(property) + && property.name.text === 'canPlaceEdit')) + assert.ok(returnedPermissions.properties.some(property => ts.isShorthandPropertyAssignment(property) + && property.name.text === 'canDayEdit')) + for (const name of ['TourPlannerRail', 'TourPlannerToursRail', 'ToursSidebar', 'TourDetailDialog']) { const tags = jsxTags(page, name) assert.equal(tags.length, name === 'TourDetailDialog' ? 2 : 1) for (const tag of tags) { - assert.match(tag.getText(page), /canEdit=\{canPlaceEdit\}/) - assert.match(tag.getText(page), /canAssign=\{canDayEdit\}/) + const canEdit = tag.attributes.properties.find(property => ts.isJsxAttribute(property) + && property.name.getText(page) === 'canEdit') + const canAssign = tag.attributes.properties.find(property => ts.isJsxAttribute(property) + && property.name.getText(page) === 'canAssign') + assert.ok(canEdit && ts.isJsxAttribute(canEdit) && canEdit.initializer + && ts.isJsxExpression(canEdit.initializer) && canEdit.initializer.expression?.getText(page) === 'canPlaceEdit') + assert.ok(canAssign && ts.isJsxAttribute(canAssign) && canAssign.initializer + && ts.isJsxExpression(canAssign.initializer) && canAssign.initializer.expression?.getText(page) === 'canDayEdit') } } - assert.match(pageText, /onMapClick=\{canPlaceEdit && !tourPlanner\.isSaving && \(tourPlanner\.mode\.type === 'new-draft' \|\| tourPlanner\.mode\.type === 'edit-saved'\)/) + + const mapClickGate = collect(page, node => ts.isJsxAttribute(node) + && node.name.getText(page) === 'onMapClick' + && node.initializer && ts.isJsxExpression(node.initializer) + && !!node.initializer.expression && node.initializer.expression.getText(page).includes('canPlaceEdit')) + assert.ok(mapClickGate.length > 0) }) test('B4A dedicated desktop controller retains GPX, draft, focus, and saved rail wiring', () => { - const entry = collect(page, node => ts.isFunctionDeclaration(node) - && node.name?.text === 'TripPlannerPageDesktop')[0] - assert.ok(entry) - assert.equal(collect(entry, node => ts.isCallExpression(node) - && node.expression.getText(page) === 'useTourPlanner').length, 1) - assert.doesNotMatch(hookText, /\buseTourPlanner\b/) - for (const mode of ['view-gpx', 'new-draft', 'edit-saved']) assert.ok(pageText.includes(`tourPlanner.mode.type === '${mode}'`)) - assert.match(pageText, /readOnlyGpxAnalysis\?\.routeCoordinates/) + assert.ok(callNamed(pageController, 'useTourPlanner')) + assert.ok(pageText.includes('const pageState = useTripPlannerPage()')) + assert.ok(pageControllerText.includes("tourPlanner.mode.type === 'view-gpx'")) + for (const mode of ['new-draft', 'edit-saved']) assert.ok(pageText.includes(`tourPlanner.mode.type === '${mode}'`)) + assert.match(pageControllerText, /readOnlyGpxAnalysis\?\.routeCoordinates/) assert.match(pageText, /routeProfileFocus=\{tourPlanner\.routeProfileFocus\}/) assert.match(pageText, /tourPlanner\.viewGpxTour\(tour, place\?\.route_geometry \?\? null\)/) - assert.match(pageText, /onSaved:[\s\S]*?upsertTour\(result\.tour\)/) + assert.match(pageControllerText, /onSaved:[\s\S]*?upsertTour\(result\.tour\)/) assert.doesNotMatch(pageText, /setSelectedPlaceId\(result\.tour/) })