mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
test(tours): update planner wiring and stabilize date fixture
This commit is contained in:
@@ -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(<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();
|
||||
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(<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 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user