test(tours): update planner wiring and stabilize date fixture

This commit is contained in:
elmocito
2026-10-05 00:55:58 +02:00
parent 562d8c41d2
commit 0c2dbdf03b
2 changed files with 127 additions and 32 deletions
@@ -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 () => { it('FE-PLANNER-INSPECTOR-2472: the own e-mail and hours show, the hours ahead of looked-up ones', async () => {
const user = userEvent.setup(); vi.useFakeTimers({ toFake: ['Date'] });
const week = JSON.stringify(Array.from({ length: 7 }, (_, i) => (i === 6 ? { closed: true } : { closed: false, open: '08:00', close: '12:00' }))); try {
const p = buildPlace({ id: 302, email: 'hi@bakery.test', opening_hours: week } as any); vi.setSystemTime(new Date('2026-06-02T12:00:00Z'));
render(<PlaceInspector {...defaultProps} place={p} />); const user = userEvent.setup();
expect(screen.getByRole('link', { name: /hi@bakery\.test/ })).toHaveAttribute('href', 'mailto:hi@bakery.test'); const week = JSON.stringify(Array.from({ length: 7 }, (_, i) => (i === 6 ? { closed: true } : { closed: false, open: '08:00', close: '12:00' })));
expect(screen.getByText('Opening Hours')).toBeInTheDocument(); const placeWithHours = { id: 302, email: 'hi@bakery.test', opening_hours: week };
await user.click(screen.getByRole('button', { expanded: false, name: /08:00|Show/ })); const p = buildPlace(placeWithHours);
expect(screen.getByText(/Sunday: Closed/)).toBeInTheDocument(); render(<PlaceInspector {...defaultProps} place={p} />);
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 ────────────────────────────────────────────────────── // ── File size display ──────────────────────────────────────────────────────
+112 -24
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync, readdirSync } from 'node:fs'
import { resolve } from 'node:path' import { join, resolve } from 'node:path'
import * as ts from 'typescript' import * as ts from 'typescript'
import { test, vi } from 'vitest' 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 pagePath = 'client/src/pages/TripPlannerPage.tsx'
const hookPath = 'client/src/pages/tripPlanner/useTripPlanner.ts' 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 pageText = readFileSync(resolve(process.cwd(), 'src/pages/TripPlannerPage.tsx'), 'utf8')
const hookText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useTripPlanner.ts'), '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 parse = (file: string, text: string) => ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
const page = parse(pagePath, pageText) const page = parse(pagePath, pageText)
const hook = parse(hookPath, hookText) const hook = parse(hookPath, hookText)
const pageController = parse(pageControllerPath, pageControllerText)
function collect(source: ts.Node, predicate: (node: ts.Node) => boolean): ts.Node[] { function collect(source: ts.Node, predicate: (node: ts.Node) => boolean): ts.Node[] {
const matches: ts.Node[] = [] const matches: ts.Node[] = []
@@ -27,20 +30,42 @@ function collect(source: ts.Node, predicate: (node: ts.Node) => boolean): ts.Nod
return matches return matches
} }
function initializer(name: string): ts.Expression { function initializer(source: ts.SourceFile, name: string): ts.Expression {
const declaration = collect(hook, node => ts.isVariableDeclaration(node) const declaration = collect(source, node => ts.isVariableDeclaration(node)
&& ts.isIdentifier(node.name) && node.name.text === name)[0] && ts.isIdentifier(node.name) && node.name.text === name)[0]
assert.ok(declaration && ts.isVariableDeclaration(declaration) && declaration.initializer, name) assert.ok(declaration && ts.isVariableDeclaration(declaration) && declaration.initializer, name)
return declaration.initializer 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)) return collect(source, node => (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node))
&& node.tagName.getText(source) === name) && 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', () => { 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, { const diagnostics = ts.transpileModule(source.text, {
fileName: source.fileName, fileName: source.fileName,
reportDiagnostics: true, 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', () => { 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) const branches = collect(tabs, node => ts.isConditionalExpression(node)
&& node.whenTrue.getText(hook).includes("id: 'tour-planner'")) && node.whenTrue.getText(hook).includes("id: 'tour-planner'"))
assert.equal(branches.length, 1) 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', () => { 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.ok(ts.isConditionalExpression(selection))
assert.equal(selection.condition.getText(hook), 'toursEnabled && selectedPlaceId') assert.equal(selection.condition.getText(hook), 'toursEnabled && selectedPlaceId')
assert.match(selection.whenTrue.getText(hook), /^tours\.find\(\w+ => \w+\.place_id === selectedPlaceId\) \?\? null$/) assert.match(selection.whenTrue.getText(hook), /^tours\.find\(\w+ => \w+\.place_id === selectedPlaceId\) \?\? null$/)
assert.equal(selection.whenFalse.kind, ts.SyntaxKind.NullKeyword) assert.equal(selection.whenFalse.kind, ts.SyntaxKind.NullKeyword)
}) })
test('B4A approved permissions reach every Tours surface and gate map edits', () => { test('B4A desktop page delegates Tours state and permissions to its page hook', () => {
assert.match(pageText, /const canPlaceEdit = can\('place_edit', trip\)/) const pageHookImport = collect(page, node => ts.isImportDeclaration(node)
assert.match(pageText, /const canDayEdit = can\('day_edit', trip\)/) && ts.isStringLiteral(node.moduleSpecifier)
assert.match(pageText, /useTourPlanner\(\{\s*tripId,\s*canEdit: canPlaceEdit,\s*canAssign: canDayEdit,/) && 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']) { for (const name of ['TourPlannerRail', 'TourPlannerToursRail', 'ToursSidebar', 'TourDetailDialog']) {
const tags = jsxTags(page, name) const tags = jsxTags(page, name)
assert.equal(tags.length, name === 'TourDetailDialog' ? 2 : 1) assert.equal(tags.length, name === 'TourDetailDialog' ? 2 : 1)
for (const tag of tags) { for (const tag of tags) {
assert.match(tag.getText(page), /canEdit=\{canPlaceEdit\}/) const canEdit = tag.attributes.properties.find(property => ts.isJsxAttribute(property)
assert.match(tag.getText(page), /canAssign=\{canDayEdit\}/) && 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', () => { test('B4A dedicated desktop controller retains GPX, draft, focus, and saved rail wiring', () => {
const entry = collect(page, node => ts.isFunctionDeclaration(node) assert.ok(callNamed(pageController, 'useTourPlanner'))
&& node.name?.text === 'TripPlannerPageDesktop')[0] assert.ok(pageText.includes('const pageState = useTripPlannerPage()'))
assert.ok(entry) assert.ok(pageControllerText.includes("tourPlanner.mode.type === 'view-gpx'"))
assert.equal(collect(entry, node => ts.isCallExpression(node) for (const mode of ['new-draft', 'edit-saved']) assert.ok(pageText.includes(`tourPlanner.mode.type === '${mode}'`))
&& node.expression.getText(page) === 'useTourPlanner').length, 1) assert.match(pageControllerText, /readOnlyGpxAnalysis\?\.routeCoordinates/)
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.match(pageText, /routeProfileFocus=\{tourPlanner\.routeProfileFocus\}/) assert.match(pageText, /routeProfileFocus=\{tourPlanner\.routeProfileFocus\}/)
assert.match(pageText, /tourPlanner\.viewGpxTour\(tour, place\?\.route_geometry \?\? null\)/) 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/) assert.doesNotMatch(pageText, /setSelectedPlaceId\(result\.tour/)
}) })