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/)
})