test: close the remaining gaps and raise the thresholds

Pushes lines from 95.2% to 98.1% and branches from 85.2% to 89.9%, mostly
in the release-notes modal, the reservation overlay, the journey entry sheet
and the trip dialogs.
This commit is contained in:
Maurice
2026-07-29 10:24:39 +02:00
committed by Maurice
parent b80bd3992b
commit 02ad6c3d16
27 changed files with 3332 additions and 77 deletions
@@ -1306,3 +1306,544 @@ describe('AdminPluginsPanel — security footer', () => {
expect(screen.getByText('What "Signed" means')).toBeInTheDocument()
})
})
// FE-W5PLG-001 to FE-W5PLG-014 — toolbar filters and sorting, the error-log and
// allowed-hosts dialogs, and the drag-to-install overlay.
describe('AdminPluginsPanel toolbar and dialogs', () => {
const row = (over: Record<string, unknown> = {}) => plugin({
id: 'a-widget', name: 'Alpha Widget', description: 'a widget', type: 'widget',
version: '1.0.0', status: 'active', enabled: 1, operatorEgress: false,
...over,
})
function mockPanel(rows: Record<string, unknown>[], registry: unknown = []) {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: rows })),
http.get('*/api/admin/plugins/registry', () => HttpResponse.json(registry)),
)
}
const three = () => [
row(),
row({ id: 'b-integration', name: 'Beta Bridge', description: 'a bridge', type: 'integration', enabled: 0, status: 'inactive' }),
row({ id: 'c-page', name: 'Gamma Page', description: 'a page', type: 'page', enabled: 1, status: 'error', last_error: 'boom' }),
]
const pickFilter = async (user: ReturnType<typeof userEvent.setup>, filter: RegExp, option: RegExp) => {
await user.click(screen.getByTitle(filter))
await user.click(screen.getByRole('button', { name: option }))
}
it('FE-W5PLG-001: the search box narrows the installed list by name and description', async () => {
const user = userEvent.setup()
mockPanel(three())
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await user.type(screen.getByPlaceholderText(/search/i), 'bridge')
await waitFor(() => expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument())
expect(screen.getByText('Beta Bridge')).toBeInTheDocument()
})
it('FE-W5PLG-002: a search with no hits shows the no-match state', async () => {
const user = userEvent.setup()
mockPanel(three())
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await user.type(screen.getByPlaceholderText(/search/i), 'zzzz')
expect(await screen.findByText(/no.*match/i)).toBeInTheDocument()
})
it('FE-W5PLG-003: the type filter keeps only plugins of that type', async () => {
const user = userEvent.setup()
mockPanel(three())
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await pickFilter(user, /^Type:/, /^Widget$/)
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
expect(screen.getByText('Alpha Widget')).toBeInTheDocument()
})
it('FE-W5PLG-004: the status filter separates active, off and errored plugins', async () => {
const user = userEvent.setup()
mockPanel(three())
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await pickFilter(user, /^Status:/, /^Active$/)
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
expect(screen.queryByText('Gamma Page')).not.toBeInTheDocument()
await pickFilter(user, /^Status:/, /^Off$/)
await waitFor(() => expect(screen.getByText('Beta Bridge')).toBeInTheDocument())
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
await pickFilter(user, /^Status:/, /^Error$/)
await waitFor(() => expect(screen.getByText('Gamma Page')).toBeInTheDocument())
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
})
it('FE-W5PLG-005: the update filter and the updates-first sort both use the registry version', async () => {
const user = userEvent.setup()
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9' }])
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument())
await pickFilter(user, /^Sort:/, /^Updates first$/)
await waitFor(() => {
const names = screen.getAllByText(/Alpha Widget|Beta Bridge|Gamma Page/).map(e => e.textContent)
expect(names[0]).toBe('Gamma Page')
})
await pickFilter(user, /^Status:/, /^Update available$/)
await waitFor(() => expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument())
expect(screen.getByText('Gamma Page')).toBeInTheDocument()
})
it('FE-W5PLG-006: switching tabs snaps a tab-only sort key back to name', async () => {
const user = userEvent.setup()
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9' }])
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await pickFilter(user, /^Sort:/, /^Updates first$/)
expect(screen.getByTitle(/^Sort: Updates first$/)).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: /Discover/ }))
await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument())
})
it('FE-W5PLG-007: the registry sorts by downloads and by review date', async () => {
const user = userEvent.setup()
const reg = [
{ id: 'r-old', name: 'Old Tool', author: 'a', description: 'd', type: 'widget', downloadCount: 10, reviewedAt: '2024-01-01T00:00:00Z' },
{ id: 'r-new', name: 'New Tool', author: 'b', description: 'd', type: 'widget', downloadCount: 999, reviewedAt: '2026-01-01T00:00:00Z' },
]
mockPanel(three(), reg)
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await user.click(screen.getByRole('tab', { name: /Discover/ }))
await screen.findByText('Old Tool')
await pickFilter(user, /^Sort:/, /^Most downloads$/)
await waitFor(() => {
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
expect(names[0]).toBe('New Tool')
})
await pickFilter(user, /^Sort:/, /^Recently updated$/)
await waitFor(() => {
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
expect(names[0]).toBe('New Tool')
})
await pickFilter(user, /^Sort:/, /^Name$/)
await waitFor(() => {
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
expect(names[0]).toBe('New Tool')
})
})
it('FE-W5PLG-008: the registry search and type filter narrow the discover grid', async () => {
const user = userEvent.setup()
const reg = [
{ id: 'r-a', name: 'Mapper', author: 'ann', description: 'maps things', type: 'widget' },
{ id: 'r-b', name: 'Poster', author: 'bob', description: 'posts things', type: 'page' },
]
mockPanel(three(), reg)
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await user.click(screen.getByRole('tab', { name: /Discover/ }))
await screen.findByText('Mapper')
await pickFilter(user, /^Type:/, /^Page$/)
await waitFor(() => expect(screen.queryByText('Mapper')).not.toBeInTheDocument())
expect(screen.getByText('Poster')).toBeInTheDocument()
await user.type(screen.getByPlaceholderText(/search/i), 'ann')
await waitFor(() => expect(screen.queryByText('Poster')).not.toBeInTheDocument())
})
it('FE-W5PLG-009: the error log lists both levels and closes again', async () => {
const user = userEvent.setup()
mockPanel([row()])
server.use(
http.get('*/api/admin/plugins/a-widget/errors', () =>
HttpResponse.json({ errors: [
{ level: 'error', ts: '2026-01-01 10:00', message: 'exploded' },
{ level: 'warn', ts: '2026-01-01 10:01', message: 'wobbled' },
] }),
),
)
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget'))
await user.click(screen.getByRole('button', { name: /view error log/i }))
expect(await screen.findByText('exploded')).toBeInTheDocument()
expect(screen.getByText('wobbled')).toBeInTheDocument()
expect(screen.getByText('error')).toBeInTheDocument()
expect(screen.getByText('warn')).toBeInTheDocument()
const dialog = screen.getByText('exploded').closest('.fixed') as HTMLElement
await user.click(within(dialog).getAllByRole('button')[0])
await waitFor(() => expect(screen.queryByText('exploded')).not.toBeInTheDocument())
})
it('FE-W5PLG-010: an empty error log says so and the backdrop closes it', async () => {
const user = userEvent.setup()
mockPanel([row()])
server.use(
http.get('*/api/admin/plugins/a-widget/errors', () => HttpResponse.json({ errors: [] })),
)
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget'))
await user.click(screen.getByRole('button', { name: /view error log/i }))
const empty = await screen.findByText(/no errors/i)
fireEvent.click(empty.closest('.fixed') as HTMLElement)
await waitFor(() => expect(screen.queryByText(/no errors/i)).not.toBeInTheDocument())
})
it('FE-W5PLG-011: a plugin whose runtime cannot take extra hosts says so', async () => {
const user = userEvent.setup()
mockPanel([row()])
server.use(
http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ supported: false, hosts: [] })),
)
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget'))
await user.click(screen.getByRole('button', { name: /allowed hosts/i }))
expect(await screen.findByText(/does not|not supported|no outbound/i)).toBeInTheDocument()
})
it('FE-W5PLG-012: a failing egress lookup still opens the dialog in the unsupported state', async () => {
const user = userEvent.setup()
mockPanel([row()])
server.use(
http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
)
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget'))
await user.click(screen.getByRole('button', { name: /allowed hosts/i }))
expect(await screen.findByText(/a-widget —/)).toBeInTheDocument()
})
it('FE-W5PLG-013: dragging a file over the panel arms the drop overlay', async () => {
mockPanel([row()])
const { container } = render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
const panel = container.firstElementChild as HTMLElement
fireEvent.dragEnter(panel, { dataTransfer: { types: ['Files'] } })
expect(await screen.findByText(/drop.*to (upload|install)/i)).toBeInTheDocument()
// nested enter/leave pairs must not disarm it early
fireEvent.dragEnter(panel, { dataTransfer: { types: ['Files'] } })
fireEvent.dragLeave(panel)
expect(screen.getByText(/drop.*to (upload|install)/i)).toBeInTheDocument()
fireEvent.dragLeave(panel)
await waitFor(() => expect(screen.queryByText(/drop.*to (upload|install)/i)).not.toBeInTheDocument())
})
it('FE-W5PLG-014: dropping a plugin archive uploads it', async () => {
let uploaded = false
mockPanel([row()])
server.use(
http.post('*/api/admin/plugins/upload', () => { uploaded = true; return HttpResponse.json({ id: 'a-widget' }) }),
)
const { container } = render(
<>
<AdminPluginsPanel />
<ToastContainer />
</>,
)
await screen.findByText('Alpha Widget')
const panel = container.firstElementChild as HTMLElement
const file = new File(['zip'], 'plugin.zip', { type: 'application/zip' })
fireEvent.drop(panel, { dataTransfer: { files: [file] } })
await waitFor(() => expect(uploaded).toBe(true))
})
it('FE-W5PLG-015: the click-away layer closes an open filter menu', async () => {
const user = userEvent.setup()
mockPanel([row()])
const { container } = render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await user.click(screen.getByTitle(/^Type:/))
expect(screen.getByRole('button', { name: /^Widget$/ })).toBeInTheDocument()
const layer = container.querySelector('.fixed.inset-0.z-20') as HTMLElement
fireEvent.click(layer)
await waitFor(() => expect(screen.queryByRole('button', { name: /^Widget$/ })).not.toBeInTheDocument())
})
})
// FE-W5PLG-016 to FE-W5PLG-023 — registry cards, the detail modal and the
// dependency chips of an installed row.
describe('AdminPluginsPanel registry cards and dependency chips', () => {
const row = (over: Record<string, unknown> = {}) => plugin({
id: 'a-widget', name: 'Alpha Widget', description: 'a widget', type: 'widget',
version: '1.0.0', status: 'active', enabled: 1, operatorEgress: false,
permissions: JSON.stringify([]),
...over,
})
const regItem = (over: Record<string, unknown> = {}) => ({
id: 'r-a', name: 'Mapper', author: 'ann', description: 'maps things', type: 'widget',
latest: '2.0.0', repo: 'ann/mapper', signed: true, ...over,
})
function mockPanel(rows: Record<string, unknown>[], registry: unknown = []) {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: rows })),
http.get('*/api/admin/plugins/registry', () => HttpResponse.json(registry)),
)
}
const openDiscover = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole('tab', { name: /Discover/ }))
}
it('FE-W5PLG-016: leaving Discover snaps the downloads sort back to name', async () => {
const user = userEvent.setup()
mockPanel([row()], [regItem({ downloadCount: 5 })])
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await openDiscover(user)
await user.click(screen.getByTitle(/^Sort:/))
await user.click(screen.getByRole('button', { name: /^Most downloads$/ }))
expect(screen.getByTitle(/^Sort: Most downloads$/)).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: /Installed/ }))
await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument())
})
it('FE-W5PLG-017: an outrun release offers the newest compatible version instead of a dead button', async () => {
const user = userEvent.setup()
mockPanel([row()], [
regItem({ id: 'r-old', name: 'Older Fit', compatible: false, latestCompatible: '1.4.0', trek: '>=4', hostVersion: '3.5.0' }),
regItem({ id: 'r-dead', name: 'No Fit', compatible: false, latestCompatible: null, trek: null }),
])
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await openDiscover(user)
const offer = await screen.findByRole('button', { name: /^Install 1\.4\.0$/ })
expect(offer).not.toBeDisabled()
expect(offer).toHaveAttribute('title', expect.stringContaining('3.5.0'))
const dead = screen.getByRole('button', { name: /^Incompatible$/ })
expect(dead).toBeDisabled()
expect(dead.getAttribute('title')).toBeTruthy()
})
it('FE-W5PLG-018: an already installed registry entry cannot be installed again', async () => {
const user = userEvent.setup()
mockPanel([row({ id: 'r-a', name: 'Mapper' })], [regItem({ reviewedAt: '2026-01-01T00:00:00Z', downloadCount: 1234 })])
render(<AdminPluginsPanel />)
await screen.findByText('Mapper')
await openDiscover(user)
expect(await screen.findByText('1.2k')).toBeInTheDocument()
const installBtn = screen.getAllByRole('button', { name: /^Installed$/ })[0]
expect(installBtn).toBeDisabled()
})
it('FE-W5PLG-019: a registry card opens its detail modal by click and by keyboard', async () => {
const user = userEvent.setup()
mockPanel([row()], [regItem({ reviewedAt: '2026-01-01T00:00:00Z', homepage: 'https://mapper.example' })])
server.use(
http.get('*/api/admin/plugins/registry/r-a', () =>
HttpResponse.json({ size: 4096, manifest: { icon: 'Map', permissions: ['db:read:trips'], egress: ['api.mapper.example'], operatorEgress: false, settings: [] } })),
)
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await openDiscover(user)
const card = screen.getByText('Mapper').closest('[role="button"]') as HTMLElement
fireEvent.keyDown(card, { key: 'Enter' })
expect(await screen.findByRole('heading', { name: 'Mapper' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('heading', { name: 'Mapper' }).closest('.fixed') as HTMLElement)
await waitFor(() => expect(screen.queryByRole('heading', { name: 'Mapper' })).not.toBeInTheDocument())
fireEvent.keyDown(card, { key: ' ' })
expect(await screen.findByRole('heading', { name: 'Mapper' })).toBeInTheDocument()
})
it('FE-W5PLG-020: a failing detail fetch still shows the modal shell', async () => {
const user = userEvent.setup()
mockPanel([row()], [regItem()])
server.use(
http.get('*/api/admin/plugins/registry/r-a', () => HttpResponse.json({ error: 'gone' }, { status: 404 })),
)
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await openDiscover(user)
await user.click(screen.getByText('Mapper'))
expect(await screen.findByRole('heading', { name: 'Mapper' })).toBeInTheDocument()
expect(screen.getByText(/ann · v2\.0\.0/)).toBeInTheDocument()
})
it('FE-W5PLG-021: dependency chips flag a disabled addon and a missing plugin', async () => {
mockPanel([row({
dependencies: { requiredAddons: ['budget', 'vacay'], pluginDependencies: [{ id: 'dep-a', version: '^1' }, { id: 'dep-b', version: '^2' }] },
dependencyStatus: 'blocked',
dependencyIssues: { disabledAddons: ['vacay'], missing: [{ id: 'dep-a' }], versionMismatch: [{ id: 'dep-b' }] },
})])
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
expect(screen.getByText(/budget/)).toBeInTheDocument()
expect(screen.getByText(/vacay/)).toBeInTheDocument()
expect(screen.getByText(/dep-a/)).toBeInTheDocument()
expect(screen.getByText(/dep-b/)).toBeInTheDocument()
})
it('FE-W5PLG-022: a plugin whose TREK range is unknown still gets an incompatibility chip', async () => {
mockPanel([row({ dependencyStatus: 'hostIncompatible', trekRange: null, hostVersion: null })])
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
expect(screen.getByText(/does not say which trek/i)).toBeInTheDocument()
})
it('FE-W5PLG-023: a registry-sourced row links to its repository and issue tracker', async () => {
const user = userEvent.setup()
mockPanel([row({ source_repo: 'ann/mapper', reviewed_at: '2026-01-01T00:00:00Z', version: null, description: null, status: 'weird' })])
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget'))
const repo = screen.getByRole('link', { name: /source|repository/i })
expect(repo).toHaveAttribute('href', 'https://github.com/ann/mapper')
expect(screen.getByRole('link', { name: /issue/i })).toHaveAttribute('href', 'https://github.com/ann/mapper/issues')
await user.click(repo)
await waitFor(() => expect(screen.queryByRole('link', { name: /issue/i })).not.toBeInTheDocument())
})
})
// FE-W5PLG-024 to FE-W5PLG-028 — the signature dialog's fingerprint rendering
// and the remaining dialog dismissals.
describe('AdminPluginsPanel signature fingerprints and dismissals', () => {
const blocked = (over: Record<string, unknown> = {}) => plugin({
source_repo: 'acme/gotify', signed: true, keyFingerprint: 'OLDKEYaa…aaaaaaaa',
updateBlock: { code: 'SIGNATURE_KEY_CHANGED', detail: 'the signing key changed', version: '2.0.0' },
...over,
})
function mockPanel(row: Record<string, unknown>, registry: unknown[] = []) {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [row] })),
http.get('*/api/admin/plugins/registry', () => HttpResponse.json(registry)),
)
}
it('FE-W5PLG-024: a long new key is shown head…tail next to the pinned one', async () => {
const key = 'untrusted comment: minisign key\nRWQf6LRCGA9iA1234567890ABCDEFGH'
mockPanel(blocked(), [{ id: 'trek-gotify', name: 'Gotify', author: 'acme', description: 'd', type: 'integration', latest: '2.0.0', authorPublicKey: key }])
render(<AdminPluginsPanel />)
await screen.findByText('Gotify')
fireEvent.click(await screen.findByRole('button', { name: /review/i }))
expect(await screen.findByText('OLDKEYaa…aaaaaaaa')).toBeInTheDocument()
expect(screen.getByText('RWQf6LRC…ABCDEFGH')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /trust the new key/i })).toBeInTheDocument()
})
it('FE-W5PLG-025: a short key is shown in full and a missing pinned key falls back to a dash', async () => {
mockPanel(blocked({ keyFingerprint: null }), [
{ id: 'trek-gotify', name: 'Gotify', author: 'acme', description: 'd', type: 'integration', latest: '2.0.0', authorPublicKey: 'SHORTKEY' },
])
render(<AdminPluginsPanel />)
await screen.findByText('Gotify')
fireEvent.click(await screen.findByRole('button', { name: /review/i }))
expect(await screen.findByText('SHORTKEY')).toBeInTheDocument()
expect(screen.getByText('—')).toBeInTheDocument()
})
it('FE-W5PLG-026: without a registry entry there is no key to offer and no override', async () => {
mockPanel(blocked())
render(<AdminPluginsPanel />)
fireEvent.click(await screen.findByRole('button', { name: /review/i }))
expect(await screen.findByText('OLDKEYaa…aaaaaaaa')).toBeInTheDocument()
expect(screen.getByText('—')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument()
})
it('FE-W5PLG-027: an incomplete signature gets its own explanation and closes on the backdrop', async () => {
mockPanel(blocked({ updateBlock: { code: 'SIGNATURE_INCOMPLETE', detail: 'only half signed', version: '2.0.0' } }))
render(<AdminPluginsPanel />)
fireEvent.click(await screen.findByRole('button', { name: /review/i }))
const detail = await screen.findByText('only half signed')
fireEvent.click(detail.closest('.fixed') as HTMLElement)
await waitFor(() => expect(screen.queryByText('only half signed')).not.toBeInTheDocument())
})
it('FE-W5PLG-028: the allowed-hosts dialog closes from its header button and its backdrop', async () => {
const user = userEvent.setup()
mockPanel(plugin({ egressHostCount: 1 }))
server.use(
http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
HttpResponse.json({ supported: true, hosts: ['gotify.mydomain.com'] })),
)
render(<AdminPluginsPanel />)
await user.click(await screen.findByRole('button', { name: /1 allowed host/i }))
const host = await screen.findByText('gotify.mydomain.com')
const dialog = host.closest('.fixed') as HTMLElement
await user.click(within(dialog).getAllByRole('button')[0])
await waitFor(() => expect(screen.queryByText('gotify.mydomain.com')).not.toBeInTheDocument())
await user.click(screen.getByRole('button', { name: /1 allowed host/i }))
const reopened = (await screen.findByText('gotify.mydomain.com')).closest('.fixed') as HTMLElement
fireEvent.click(reopened)
await waitFor(() => expect(screen.queryByText('gotify.mydomain.com')).not.toBeInTheDocument())
})
it('FE-W5PLG-029: a drop without a file is ignored', async () => {
let uploaded = false
mockPanel(plugin())
server.use(
http.post('*/api/admin/plugins/upload', () => { uploaded = true; return HttpResponse.json({ id: 'x' }) }),
)
const { container } = render(<AdminPluginsPanel />)
await screen.findByText('Gotify')
fireEvent.drop(container.firstElementChild as HTMLElement, { dataTransfer: { files: [] } })
await waitFor(() => expect(uploaded).toBe(false))
})
})
@@ -590,6 +590,7 @@ describe('BudgetCategoryTable — member chips', () => {
fireEvent.click(editors[0])
fireEvent.click(screen.getByText('bob'))
fireEvent.click(editors[0]) // picking an option leaves the dropdown open
fireEvent.click(editors[1])
fireEvent.click(screen.getByText('bob'))
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
// FE-W5CCM-001 to FE-W5CCM-022
// FE-W5CCM-001 to FE-W5CCM-024
// ChatMessages is a pure presentational component — every piece of state arrives
// as a prop from useCollabChat, so the tests drive it directly instead of going
// through CollabChat (that path is covered in CollabChat.test.tsx).
@@ -1270,7 +1270,7 @@ describe('CollabNotes', () => {
});
});
// FE-W5CNT-001 to FE-W5CNT-026
// FE-W5CNT-001 to FE-W5CNT-028
// Fills in the load/error/attachment/category branches of useCollabNotes and the
// view modal that the smoke tests above do not reach.
@@ -1602,7 +1602,10 @@ describe('CollabNotes details', () => {
it('FE-W5CNT-020: a failing upload during an edit reports an error', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 3, title: 'Edit me' })] });
serveNotesSequence([
{ notes: [buildNote({ id: 3, title: 'Edit me' })] },
[buildNote({ id: 3, title: 'Ignored reload' })],
]);
server.use(
http.put('/api/trips/1/collab/notes/3', () =>
HttpResponse.json({ note: buildNote({ id: 3, title: 'Edit me' }) }),
@@ -1616,6 +1619,7 @@ describe('CollabNotes details', () => {
pasteFile('nope.png');
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
expect(screen.queryByText('Ignored reload')).not.toBeInTheDocument();
});
it('FE-W5CNT-021: a failing attachment removal reports an error', async () => {
@@ -1645,15 +1649,17 @@ describe('CollabNotes details', () => {
buildNote({ id: 1, title: 'No timestamps', updated_at: null, created_at: null }),
buildNote({ id: 2, title: 'Pinned one', pinned: true }),
buildNote({ id: 3, title: 'Created only', updated_at: null, created_at: '2025-06-02T10:00:00.000Z' }),
buildNote({ id: 4, title: 'Also undated', updated_at: null, created_at: null }),
],
});
const known = ['No timestamps', 'Pinned one', 'Created only', 'Also undated'];
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Pinned one');
const titles = Array.from(document.querySelectorAll('span'))
.filter(el => el.childElementCount === 0)
.map(el => el.textContent)
.filter(text => ['No timestamps', 'Pinned one', 'Created only'].includes(text ?? ''));
expect(titles).toEqual(['Pinned one', 'Created only', 'No timestamps']);
.filter(text => known.includes(text ?? ''));
expect(titles).toEqual(['Pinned one', 'Created only', 'No timestamps', 'Also undated']);
});
it('FE-W5CNT-023: clicking the active category pill clears the filter again', async () => {
@@ -1723,7 +1729,7 @@ describe('CollabNotes details', () => {
content: 'See attachments',
attachments: [
{ id: 1, filename: 'a.png', original_name: 'map.png', mime_type: 'image/png', url: '/uploads/map.png' },
{ id: 2, filename: 'b.txt', original_name: 'itinerary.txt', mime_type: 'text/plain', url: '/uploads/itinerary.txt' },
{ id: 2, filename: 'b.zip', original_name: 'itinerary.zip', mime_type: 'application/zip', url: '/uploads/itinerary.zip' },
{ id: 3, filename: 'c', url: '/uploads/c' },
],
})],
@@ -1740,15 +1746,16 @@ describe('CollabNotes details', () => {
// Unknown mime type and missing name fall back to a "?" tile
expect(within(modal).getByText('?')).toBeInTheDocument();
const txtTile = within(modal).getByTitle('itinerary.txt');
expect(txtTile.style.background).toBe('var(--bg-secondary)');
fireEvent.mouseEnter(txtTile);
expect(txtTile.style.transform).toBe('scale(1.06)');
fireEvent.mouseLeave(txtTile);
expect(txtTile.style.transform).toBe('scale(1)');
fireEvent.click(txtTile);
const zipTile = within(modal).getByTitle('itinerary.zip');
expect(zipTile.style.background).toBe('var(--bg-secondary)');
expect(within(modal).getByText('ZIP')).toBeInTheDocument();
fireEvent.mouseEnter(zipTile);
expect(zipTile.style.transform).toBe('scale(1.06)');
fireEvent.mouseLeave(zipTile);
expect(zipTile.style.transform).toBe('scale(1)');
fireEvent.click(zipTile);
// FilePreviewPortal shows a download action for non-image files
expect(await screen.findByText('Download itinerary.txt')).toBeInTheDocument();
expect(await screen.findByText('Download itinerary.zip')).toBeInTheDocument();
const image = await waitFor(() => {
const img = modal.querySelector('img[alt="map.png"]') as HTMLImageElement | null;
@@ -1760,6 +1767,39 @@ describe('CollabNotes details', () => {
fireEvent.mouseLeave(image);
expect(image.style.transform).toBe('scale(1)');
fireEvent.click(image);
await waitFor(() => expect(screen.queryByText('Download itinerary.txt')).not.toBeInTheDocument());
await waitFor(() => expect(screen.queryByText('Download itinerary.zip')).not.toBeInTheDocument());
});
it('FE-W5CNT-027: a create response for a note already in the list is not added twice', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 20, title: 'Fresh note' })] });
server.use(
http.post('/api/trips/1/collab/notes', () =>
HttpResponse.json({ note: buildNote({ id: 20, title: 'Fresh note' }) }),
),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Fresh note');
await user.click(screen.getByText('New Note'));
await user.type(await screen.findByPlaceholderText('Note title'), 'Fresh note');
await user.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => expect(screen.queryByPlaceholderText('Note title')).not.toBeInTheDocument());
expect(screen.getAllByText('Fresh note')).toHaveLength(1);
});
it('FE-W5CNT-028: a second note created elsewhere is prepended to the existing list', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 20, title: 'Older note' })] });
server.use(
http.post('/api/trips/1/collab/notes', () =>
HttpResponse.json({ note: buildNote({ id: 21, title: 'Newer note' }) }),
),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Older note');
await user.click(screen.getByText('New Note'));
await user.type(await screen.findByPlaceholderText('Note title'), 'Newer note');
await user.click(screen.getByRole('button', { name: 'Create' }));
await screen.findByText('Newer note');
expect(screen.getByText('Older note')).toBeInTheDocument();
});
});
@@ -1,4 +1,4 @@
// FE-W5CNF-001 to FE-W5CNF-018
// FE-W5CNF-001 to FE-W5CNF-021
// NoteFormModal takes everything it needs as props, so the tests drive it
// directly rather than through CollabNotes.
@@ -274,7 +274,7 @@ describe('CollabPolls', () => {
});
});
// FE-W5CPL-001 to FE-W5CPL-026
// FE-W5CPL-001 to FE-W5CPL-028
// Covers the deadline maths, the voter chips, the error paths of every mutation
// and the WebSocket handler branches that the smoke tests above skip.
@@ -410,7 +410,7 @@ describe('CollabPolls details', () => {
polls: [buildPoll({
is_closed: true,
options: [
{ id: 1, text: 'Paris', voters: [{ user_id: 4, username: '', avatar_url: null }] },
{ id: 1, text: 'Paris', voters: [{ user_id: null, username: '', avatar_url: null }] },
{ id: 2, text: 'Rome', voters: [] },
],
})],
@@ -448,9 +448,9 @@ describe('CollabPolls details', () => {
expect(screen.getAllByText('Closed')).toHaveLength(2);
});
it('FE-W5CPL-014: closing a poll marks it closed and moves it out of the active list', async () => {
it('FE-W5CPL-014: closing a poll marks it closed and leaves the other one open', async () => {
let closeCalled = false;
servePolls({ polls: [buildPoll({ id: 5 })] });
servePolls({ polls: [buildPoll({ id: 5 }), buildPoll({ id: 6, question: 'Stays open?' })] });
server.use(
http.put('/api/trips/1/collab/polls/5/close', () => {
closeCalled = true;
@@ -459,11 +459,38 @@ describe('CollabPolls details', () => {
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Best destination?');
await user.click(screen.getByTitle(/close/i));
await screen.findByText('Stays open?');
// Both action buttons highlight on hover
const closeBtn = screen.getAllByTitle('Close')[0];
fireEvent.mouseEnter(closeBtn);
expect(closeBtn.style.color).toBe('var(--text-primary)');
fireEvent.mouseLeave(closeBtn);
expect(closeBtn.style.color).toBe('var(--text-faint)');
const deleteBtn = screen.getAllByTitle('Delete')[0];
fireEvent.mouseEnter(deleteBtn);
expect(deleteBtn.style.color).toBe('rgb(239, 68, 68)');
fireEvent.mouseLeave(deleteBtn);
expect(deleteBtn.style.color).toBe('var(--text-faint)');
await user.click(closeBtn);
await waitFor(() => expect(closeCalled).toBe(true));
expect(await screen.findByText(/closed/i)).toBeInTheDocument();
expect(screen.queryByTitle(/close/i)).not.toBeInTheDocument();
await waitFor(() => expect(screen.getAllByText('Closed')).toHaveLength(2));
expect(screen.getAllByTitle('Close')).toHaveLength(1);
});
it('FE-W5CPL-027: a failing poll request falls back to the empty state', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
});
it('FE-W5CPL-028: a payload without a polls key yields an empty list', async () => {
servePolls({});
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
});
it('FE-W5CPL-015: a failing close shows an error and leaves the poll open', async () => {
@@ -1,4 +1,4 @@
// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-039
// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-040
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
@@ -422,7 +422,7 @@ describe('EntryEditor', () => {
})
it('FE-JRN-EDITOR-019: reports that no external provider is connected', async () => {
server.use(http.get('/api/addons', () => HttpResponse.json({ addons: [] })))
server.use(http.get('/api/addons', () => HttpResponse.json({})))
const user = userEvent.setup()
mountEditor(buildEntry({ id: 10 }))
@@ -561,6 +561,8 @@ describe('EntryEditor', () => {
const stripImg = container.querySelector('img[src="/api/photos/100/thumbnail"]') as HTMLImageElement
fireEvent.error(stripImg)
expect(stripImg.getAttribute('src')).toBe('/api/photos/100/original')
fireEvent.error(stripImg)
expect(stripImg.getAttribute('src')).toBe('/api/photos/100/original')
})
it('FE-JRN-EDITOR-029: reorders photos locally even when persisting the order fails', async () => {
@@ -712,7 +714,8 @@ describe('EntryEditor', () => {
const user = userEvent.setup()
mountEditor(buildEntry({
id: 10,
photos: [{ ...buildPhoto(100), provider: 'immich', asset_id: 'asset-1' }],
// The locally uploaded photo carries no provider and must be ignored here.
photos: [buildPhoto(99), { ...buildPhoto(100), provider: 'immich', asset_id: 'asset-1' }],
}))
await user.click(screen.getByRole('button', { name: 'External photos' }))
@@ -725,11 +728,24 @@ describe('EntryEditor', () => {
it('FE-JRN-EDITOR-035: falls back to the day-wide provider search without a place name', async () => {
useConnectedImmich()
const user = userEvent.setup()
mountEditor(buildEntry({ id: 10, location_lat: 41.9, location_lng: 12.5 }))
mountEditor(buildEntry({ location_lat: 41.9, location_lng: 12.5 }))
await user.click(screen.getByRole('button', { name: 'External photos' }))
expect(await screen.findByText('All photos from this day')).toBeInTheDocument()
expect(await screen.findByTestId('journey-external-provider-immich')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-040: ignores a link call that comes back without a photo', async () => {
server.use(http.post('/api/journeys/entries/10/link-photo', () => HttpResponse.json(null)))
const user = userEvent.setup()
const { container } = mountEditor(buildEntry({ id: 10 }), { galleryPhotos: [buildGalleryPhoto(200)] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
await user.click(screen.getAllByAltText('')[0])
await waitFor(() => expect(screen.getAllByAltText('')).toHaveLength(1))
expect(container.querySelector('.w-20.h-20')).not.toBeInTheDocument()
})
it('FE-JRN-EDITOR-036: shows the upload progress while photos are in flight', async () => {
@@ -794,5 +810,9 @@ describe('EntryEditor', () => {
await user.click(amazing)
expect(amazing.className).not.toContain('border-zinc-200')
expect(amazing.getAttribute('style')).toBeTruthy()
await user.click(amazing)
expect(amazing.className).toContain('border-zinc-200')
expect(amazing.getAttribute('style')).toBe('')
})
})
+385
View File
@@ -515,3 +515,388 @@ describe('downloadTripPDF', () => {
expect(getIframe()!.srcdoc).not.toContain('class="plugin-sections')
})
})
// FE-W5PDF-001 to FE-W5PDF-018 — multi-day transport spans, the remaining
// reservation subtitles, accommodation phases and cover fallbacks.
describe('downloadTripPDF remaining branches', () => {
const dA = { id: 10, day_number: 1, title: 'Day A', date: '2025-06-01' } as any
const dB = { id: 11, day_number: 2, title: null, date: null } as any
const dC = { id: 12, day_number: 3, title: 'Day C', date: '2025-06-03' } as any
const spanArgs = (reservations: any[], overrides: Record<string, unknown> = {}) => ({
...minimalArgs,
trip: { id: 10, title: 'Span Trip', description: null, cover_image: null } as any,
days: [dA, dB, dC],
reservations,
...overrides,
})
const srcdoc = () => getIframe()!.srcdoc
it('FE-W5PDF-001: a multi-day cruise is labelled start / ongoing / end with the right times', async () => {
await downloadTripPDF(spanArgs([{
id: 500, title: 'Nordic Cruise', type: 'cruise', day_id: 10, end_day_id: 12,
reservation_time: '2025-06-01T09:00', reservation_end_time: '2025-06-03T18:00',
}]))
const html = srcdoc()
expect(html).toContain('reservations.span.start: Nordic Cruise')
expect(html).toContain('reservations.span.ongoing: Nordic Cruise')
expect(html).toContain('reservations.span.end: Nordic Cruise')
expect(html).toContain('09:00')
expect(html).toContain('18:00')
})
it('FE-W5PDF-002: a multi-day flight uses departure/arrival wording', async () => {
await downloadTripPDF(spanArgs([{
id: 501, title: 'Red Eye', type: 'flight', day_id: 10, end_day_id: 11,
reservation_time: '2025-06-01T23:00', reservation_end_time: '2025-06-02T06:00',
metadata: JSON.stringify({}),
}]))
const html = srcdoc()
expect(html).toContain('reservations.span.departure: Red Eye')
expect(html).toContain('reservations.span.arrival: Red Eye')
})
it('FE-W5PDF-003: a multi-day car hire is labelled pickup/return and skips the middle day', async () => {
await downloadTripPDF(spanArgs([{
id: 502, title: 'Rental', type: 'car', day_id: 10, end_day_id: 12,
reservation_time: '2025-06-01T10:00', reservation_end_time: '2025-06-03T10:00',
}]))
const html = srcdoc()
expect(html).toContain('reservations.span.pickup: Rental')
expect(html).toContain('reservations.span.return: Rental')
expect(html).not.toContain('reservations.span.active')
})
it('FE-W5PDF-004: hotels, day-less and unknown-day reservations are all skipped', async () => {
await downloadTripPDF(spanArgs([
{ id: 503, title: 'Hotel Row', type: 'hotel', day_id: 10 },
{ id: 504, title: 'Floating', type: 'event', day_id: null },
{ id: 505, title: 'Ghost Span', type: 'train', day_id: 900, end_day_id: 901 },
]))
const html = srcdoc()
expect(html).not.toContain('Hotel Row')
expect(html).not.toContain('Floating')
expect(html).not.toContain('Ghost Span')
expect(html).toContain('dayplan.emptyDay')
})
it('FE-W5PDF-005: a single-leg train renders its number, platform, seat and route', async () => {
await downloadTripPDF(spanArgs([{
id: 506, title: 'ICE 599', type: 'train', day_id: 10,
reservation_time: '10:15',
endpoints: [{ sequence: 1, code: 'BER' }, { sequence: 0, code: 'FRA' }],
metadata: { train_number: 'ICE 599', platform: '7', seat: '21A' },
}]))
const html = srcdoc()
expect(html).toContain('ICE 599 · Gl. 7 · Seat 21A · FRA → BER')
})
it('FE-W5PDF-006: a multi-leg train renders one line per leg', async () => {
await downloadTripPDF(spanArgs([{
id: 507, title: 'Alpine Run', type: 'train', day_id: 10,
metadata: JSON.stringify({
legs: [
{ train_number: 'IC 1', platform: '3', from: 'BER', to: 'MUC' },
{ train_number: 'EC 2', from: 'MUC', to: 'ZRH' },
],
}),
}]))
const html = srcdoc()
expect(html).toContain('IC 1 · Gl. 3 · BER → MUC')
expect(html).toContain('EC 2 · MUC → ZRH')
})
it('FE-W5PDF-007: restaurant, event, tour and unknown types get their own subtitles', async () => {
await downloadTripPDF(spanArgs([
{ id: 508, title: 'Dinner', type: 'restaurant', day_id: 10, metadata: { party_size: 4 } },
{ id: 509, title: 'Concert', type: 'event', day_id: 10, metadata: { venue: 'Arena' } },
{ id: 510, title: 'Walk', type: 'tour', day_id: 10, metadata: { operator: 'GuideCo' } },
{ id: 511, title: 'Mystery', type: 'submarine', day_id: 10, metadata: null, location: 'Docks' },
]))
const html = srcdoc()
expect(html).toContain('4 guests')
expect(html).toContain('Arena')
expect(html).toContain('GuideCo')
expect(html).toContain('Mystery')
expect(html).toContain('Docks')
})
it('FE-W5PDF-008: reservation positions come from day_positions, then the plan position, then the end', async () => {
await downloadTripPDF(spanArgs([
{ id: 512, title: 'By Numeric Key', type: 'event', day_id: 10, day_positions: { 10: -5 } },
{ id: 513, title: 'By String Key', type: 'event', day_id: 10, day_positions: { '10': -4 } },
{ id: 514, title: 'By Plan Position', type: 'event', day_id: 10, day_plan_position: -3 },
{ id: 515, title: 'By Fallback', type: 'event', day_id: 10 },
]))
const html = srcdoc()
expect(html.indexOf('By Numeric Key')).toBeLessThan(html.indexOf('By String Key'))
expect(html.indexOf('By String Key')).toBeLessThan(html.indexOf('By Plan Position'))
expect(html.indexOf('By Plan Position')).toBeLessThan(html.indexOf('By Fallback'))
})
it('FE-W5PDF-009: a flight leg without a route still lists airline and number', async () => {
await downloadTripPDF(spanArgs([{
id: 516, title: 'Charter', type: 'flight', day_id: 10,
metadata: JSON.stringify({
legs: [
{ airline: 'AirX', flight_number: 'X1' },
{ airline: 'AirX', flight_number: 'X2', from: 'AAA', to: 'BBB' },
],
}),
}]))
const html = srcdoc()
expect(html).toContain('AirX · X1')
expect(html).toContain('AirX · X2 · AAA → BBB')
})
it('FE-W5PDF-010: a single-leg flight with waypoints joins the whole route', async () => {
await downloadTripPDF(spanArgs([{
id: 517, title: 'Long Haul', type: 'flight', day_id: 10,
endpoints: [{ sequence: 0, code: 'FRA' }, { sequence: 1, name: 'Berlin' }, { sequence: 2, code: 'HND' }],
metadata: { airline: 'LH', flight_number: 'LH7' },
}]))
expect(srcdoc()).toContain('LH · LH7 · FRA → Berlin → HND')
})
it('FE-W5PDF-011: an assignment without a place contributes nothing', async () => {
await downloadTripPDF(spanArgs([], {
assignments: { '10': [{ id: 1, day_id: 10, order_index: 0, place: null }] } as any,
}))
expect(srcdoc()).toContain('class="day-body"')
expect(srcdoc()).not.toContain('class="place-card"')
})
it('FE-W5PDF-012: a bare place renders without badge, address, coordinates or chips', async () => {
await downloadTripPDF(spanArgs([], {
assignments: {
'10': [{ id: 1, day_id: 10, order_index: 0, place: { id: 70, name: 'Bare Place', price: '0' } }],
} as any,
}))
const html = srcdoc()
expect(html).toContain('Bare Place')
expect(html).not.toContain('class="cat-badge"')
expect(html).not.toContain('<div class="chips">')
expect(html).toContain('class="place-thumb-fallback"')
})
it('FE-W5PDF-013: coordinates are printed for a place that has no address', async () => {
await downloadTripPDF(spanArgs([], {
assignments: {
'10': [{ id: 1, day_id: 10, order_index: 0, place: { id: 71, name: 'Pin Only', lat: 48.858093, lng: 2.294694 } }],
} as any,
}))
expect(srcdoc()).toContain('48.85809, 2.29469')
})
it('FE-W5PDF-014: a note without a time renders only its text and falls back to the default icon', async () => {
await downloadTripPDF(spanArgs([], {
dayNotes: [{ id: 1, day_id: 10, text: 'Just a note', time: null, icon: 'NotAnIcon', sort_order: 0 }] as any,
}))
const html = srcdoc()
expect(html).toContain('Just a note')
expect(html).not.toContain('class="note-time"')
})
it('FE-W5PDF-015: check-in, middle and check-out days each get their own accommodation block', async () => {
server.use(
http.get('/api/trips/:id/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, start_day_id: 10, end_day_id: 12, place_name: 'Hotel Nord', place_address: 'Main St',
check_in: '15:00', check_out: '10:00', confirmation: 'CONF-9', notes: 'Late arrival',
}],
}),
),
)
await downloadTripPDF(spanArgs([]))
const html = srcdoc()
expect(html).toContain('reservations.meta.checkIn')
expect(html).toContain('reservations.meta.linkAccommodation')
expect(html).toContain('reservations.meta.checkOut')
expect(html).toContain('Hotel Nord')
expect(html).toContain('Main St')
expect(html).toContain('Late arrival')
expect(html).toContain('CONF-9')
expect(html).toContain('class="day-accommodations single"')
})
it('FE-W5PDF-016: two accommodations on one day are ordered by their start day', async () => {
server.use(
http.get('/api/trips/:id/accommodations', () =>
HttpResponse.json({
accommodations: [
{ id: 2, start_day_id: 11, end_day_id: 12, place_name: 'Later Inn', place_address: null, check_in: null, check_out: null, confirmation: null },
{ id: 1, start_day_id: 10, end_day_id: 12, place_name: 'Earlier Inn', place_address: null, check_in: null, check_out: null, confirmation: null },
],
}),
),
)
await downloadTripPDF(spanArgs([]))
const html = srcdoc()
expect(html.indexOf('Earlier Inn')).toBeLessThan(html.indexOf('Later Inn'))
// the shared days list two hotels, so they lose the single-column class
expect(html).toContain('class="day-accommodations "')
})
it('FE-W5PDF-017: a trip without dated days prints no date range and no day dates', async () => {
await downloadTripPDF({
...minimalArgs,
trip: { id: 1, title: 'Undated', description: null, cover_image: null } as any,
days: [{ id: 1, day_number: 1, title: null, date: null }] as any[],
})
const html = srcdoc()
expect(html).not.toContain('class="cover-dates"')
expect(html).not.toContain('class="day-date"')
expect(html).toContain('class="cover-circle-ph"')
})
it('FE-W5PDF-018: the print button asks the preview frame to print', async () => {
await downloadTripPDF(minimalArgs)
const iframe = getIframe()!
const print = vi.fn()
Object.defineProperty(iframe, 'contentWindow', { value: { print }, configurable: true })
document.getElementById('pdf-print-btn')!.click()
expect(print).toHaveBeenCalled()
})
it('FE-W5PDF-019: an external cover image is used verbatim, a non-image one is dropped', async () => {
await downloadTripPDF({
...minimalArgs,
trip: { id: 1, title: 'Cover Trip', description: null, cover_image: 'https://cdn.example.com/a.jpg' } as any,
})
expect(srcdoc()).toContain('https://cdn.example.com/a.jpg')
getOverlay()?.remove()
await downloadTripPDF({
...minimalArgs,
trip: { id: 1, title: 'Cover Trip', description: null, cover_image: '/uploads/cover.svg' } as any,
})
expect(srcdoc()).not.toContain('/uploads/cover.svg')
expect(srcdoc()).toContain('class="cover-circle-ph"')
})
it('FE-W5PDF-020: plugin sections render their paragraphs and tables', async () => {
server.use(
http.get('/api/pdf-sections/:tripId', () =>
HttpResponse.json({
sections: [
{ title: 'Packing', paragraphs: ['Bring a towel'], table: { headers: ['Item', 'Qty'], rows: [['Socks', '3']] } },
{ title: 'Bare', paragraphs: null, table: null },
],
}),
),
)
await downloadTripPDF(minimalArgs)
const html = srcdoc()
expect(html).toContain('Packing')
expect(html).toContain('Bring a towel')
expect(html).toContain('<th>Item</th>')
expect(html).toContain('<td>Socks</td>')
expect(html).toContain('Bare')
})
})
// FE-W5PDF-021 to FE-W5PDF-026 — the defaulting arms of the exporter.
describe('downloadTripPDF defaults', () => {
type Args = Parameters<typeof downloadTripPDF>[0]
const srcdoc = () => getIframe()!.srcdoc
it('FE-W5PDF-021: a call without days, places, notes or a translator still produces a document', async () => {
await downloadTripPDF({ trip: { id: 1 }, assignments: {}, locale: 'en-US' } as unknown as Args)
const html = srcdoc()
// no translator, no days, no places, no notes
expect(html).toContain('pdf.travelPlan')
expect(html).toContain('<div class="cover-title">My Trip</div>')
expect(html).toContain('<div class="cover-stat-num">0</div>')
})
it('FE-W5PDF-022: an accommodations response without the key degrades to no hotels', async () => {
server.use(
http.get('/api/trips/:id/accommodations', () => HttpResponse.json({})),
http.get('/api/pdf-sections/:tripId', () => HttpResponse.json({})),
)
await downloadTripPDF({ ...minimalArgs } as unknown as Args)
expect(srcdoc()).not.toContain('day-accommodations-overview"')
expect(srcdoc()).not.toContain('class="plugin-sections')
})
it('FE-W5PDF-023: items without an explicit order fall back to position zero', async () => {
await downloadTripPDF({
...minimalArgs,
days: [{ id: 1, day_number: 1, title: 'Only Day', date: '2025-06-01' }] as any[],
assignments: { '1': [{ id: 1, day_id: 1, order_index: null, place: { id: 5, name: 'Unordered Place' } }] } as any,
dayNotes: [{ id: 2, day_id: 1, text: 'Unordered Note', sort_order: null, icon: 'Info' }] as any,
})
const html = srcdoc()
expect(html).toContain('Unordered Place')
expect(html).toContain('Unordered Note')
})
it('FE-W5PDF-024: an empty metadata string and a missing title are handled', async () => {
await downloadTripPDF({
...minimalArgs,
trip: { id: 1, title: null, description: null, cover_image: null } as any,
days: [{ id: 1, day_number: 1, title: null, date: '2025-06-01' }] as any[],
reservations: [{ id: 1, title: null, type: 'restaurant', day_id: 1, metadata: '' }] as any[],
})
const html = srcdoc()
expect(html).toContain('<title>pdf.travelPlan</title>')
expect(html).toContain('note-card')
expect(html).toContain('Day 1')
})
it('FE-W5PDF-025: an extensionless relative image url is resolved against the origin', async () => {
await downloadTripPDF({
...minimalArgs,
days: [{ id: 1, day_number: 1, title: 'Only Day', date: '2025-06-01' }] as any[],
assignments: {
'1': [{ id: 1, day_id: 1, order_index: 0, place: { id: 5, name: 'Proxy Photo', image_url: 'uploads/places/photo.jpg' } }],
} as any,
})
// no leading slash: absUrl has to insert one
expect(srcdoc()).toContain('http://localhost:3000/uploads/places/photo.jpg')
})
it('FE-W5PDF-026: the last day of a span without an end time prints no time', async () => {
await downloadTripPDF({
...minimalArgs,
days: [
{ id: 1, day_number: 1, title: 'Start Day', date: '2025-06-01' },
{ id: 2, day_number: 2, title: 'End Day', date: '2025-06-02' },
] as any[],
reservations: [{ id: 1, title: 'Overnight Bus', type: 'bus', day_id: 1, end_day_id: 2, reservation_time: '22:00', reservation_end_time: null }] as any[],
})
const html = srcdoc()
const endSection = html.slice(html.indexOf('End Day'))
expect(html).toContain('reservations.span.start: Overnight Bus')
expect(endSection).toContain('reservations.span.end: Overnight Bus')
expect(endSection).not.toContain('22:00')
})
})
@@ -1,4 +1,4 @@
// FE-W5CAT-001 to FE-W5CAT-034
// FE-W5CAT-001 to FE-W5CAT-040
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { ComponentProps } from 'react'
import { http, HttpResponse } from 'msw'
@@ -1,4 +1,4 @@
// FE-W5ROW-001 to FE-W5ROW-055
// FE-W5ROW-001 to FE-W5ROW-056
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { ComponentProps } from 'react'
import { http, HttpResponse } from 'msw'
@@ -142,6 +142,26 @@ describe('ArtikelZeile — basics', () => {
})
})
describe('ArtikelZeile — quantity', () => {
it('FE-W5ROW-005a: committing the inline quantity saves it', async () => {
let body: Record<string, unknown> | null = null
server.use(
http.put('/api/trips/1/packing/1', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>
return HttpResponse.json({ item: buildPackingItem({ id: 1, quantity: 3 }) })
}),
)
const { container } = setup({ item: buildPackingItem({ id: 1, name: 'Tent', quantity: null } as Partial<PackingItem>) })
const qty = container.querySelector<HTMLInputElement>('.packing-row-inline-actions input')!
expect(qty).toHaveValue('1')
fireEvent.change(qty, { target: { value: '3' } })
fireEvent.blur(qty)
await waitFor(() => expect(body).toMatchObject({ quantity: 3 }))
})
})
describe('ArtikelZeile — renaming', () => {
it('FE-W5ROW-006: Enter commits the new name', async () => {
let body: Record<string, unknown> | null = null
@@ -188,6 +208,22 @@ describe('ArtikelZeile — renaming', () => {
expect(screen.getByText('Tent')).toBeInTheDocument()
})
it('FE-W5ROW-008a: a blanked placeholder row falls back to an empty field', async () => {
setup({ item: buildPackingItem({ id: 1, name: '...', category: 'Gear' }) })
fireEvent.click(screen.getByText('...'))
const input = screen.getByPlaceholderText('...')
fireEvent.change(input, { target: { value: 'Tent' } })
fireEvent.keyDown(input, { key: 'Escape' })
fireEvent.click(screen.getByText('...'))
expect(screen.getByPlaceholderText('...')).toHaveValue('')
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: ' ' } })
fireEvent.blur(screen.getByPlaceholderText('...'))
await waitFor(() => expect(screen.getByText('...')).toBeInTheDocument())
})
it('FE-W5ROW-009: a failing rename surfaces a save error', async () => {
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
setup()
@@ -605,6 +641,33 @@ describe('ArtikelZeile — weight and bag', () => {
expect(selected.style.background).toBe('var(--bg-tertiary)')
})
it('FE-W5ROW-040a: hovering the selected bag entry leaves it untouched', () => {
const { container } = setup({
item: buildPackingItem({ id: 1, name: 'Tent', bag_id: 7 } as Partial<PackingItem>),
bagTrackingEnabled: true,
bags: BAGS,
})
fireEvent.click(bagButton(container))
const selected = screen.getByRole('button', { name: 'Carry-on' })
fireEvent.mouseEnter(selected)
expect(selected.style.background).toBe('var(--bg-tertiary)')
fireEvent.mouseLeave(selected)
expect(selected.style.background).toBe('var(--bg-tertiary)')
})
it('FE-W5ROW-040b: a bag id the trip no longer knows falls back to the neutral border', () => {
const { container } = setup({
item: buildPackingItem({ id: 1, name: 'Tent', bag_id: 99 } as Partial<PackingItem>),
bagTrackingEnabled: true,
bags: BAGS,
})
const trigger = bagButton(container)
expect(trigger.style.border).toBe('2.5px solid var(--border-primary)')
expect(trigger.style.background).toBe('var(--border-primary)30')
})
it('FE-W5ROW-041: creating a bag inline assigns it right away', async () => {
const created = { id: 12, name: 'Duffel', color: '#f97316' } as unknown as PackingBag
const onCreateBag = vi.fn(async () => created)
@@ -684,6 +747,52 @@ describe('ArtikelZeile — weight and bag', () => {
expect(called).toBe(false)
})
it('FE-W5ROW-045a: a failing assign after an inline create surfaces a save error', async () => {
const created = { id: 14, name: 'Duffel', color: '#f97316' } as unknown as PackingBag
const onCreateBag = vi.fn(async () => created)
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS, onCreateBag })
fireEvent.click(bagButton(container))
fireEvent.click(screen.getByText('Add bag'))
const input = screen.getByPlaceholderText('Bag name...')
fireEvent.change(input, { target: { value: 'Duffel' } })
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
})
it('FE-W5ROW-045b: the confirm button also reports a failing assign', async () => {
const created = { id: 15, name: 'Crate', color: '#f97316' } as unknown as PackingBag
const onCreateBag = vi.fn(async () => created)
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS, onCreateBag })
fireEvent.click(bagButton(container))
fireEvent.click(screen.getByText('Add bag'))
const confirm = screen.getByPlaceholderText('Bag name...').nextElementSibling as HTMLButtonElement
fireEvent.change(screen.getByPlaceholderText('Bag name...'), { target: { value: 'Crate' } })
fireEvent.click(confirm)
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
})
it('FE-W5ROW-045c: the confirm button closes the form when creation is refused', async () => {
const onCreateBag = vi.fn(async () => undefined)
let called = false
server.use(http.put('/api/trips/1/packing/1', () => { called = true; return HttpResponse.json({ item: buildPackingItem() }) }))
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS, onCreateBag })
fireEvent.click(bagButton(container))
fireEvent.click(screen.getByText('Add bag'))
const confirm = screen.getByPlaceholderText('Bag name...').nextElementSibling as HTMLButtonElement
fireEvent.change(screen.getByPlaceholderText('Bag name...'), { target: { value: 'Crate' } })
fireEvent.click(confirm)
await waitFor(() => expect(screen.queryByPlaceholderText('Bag name...')).toBeNull())
expect(called).toBe(false)
})
it('FE-W5ROW-046: the Add bag entry highlights on hover', () => {
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS })
fireEvent.click(bagButton(container))
@@ -798,6 +907,40 @@ describe('ArtikelZeile — overflow menu', () => {
expect(called).toBe(false)
})
it('FE-W5ROW-053a: a failing clear from the menu surfaces a save error', async () => {
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
const { container } = setup({
item: buildPackingItem({ id: 1, name: 'Tent', bag_id: 7 } as Partial<PackingItem>),
bagTrackingEnabled: true,
bags: BAGS,
})
fireEvent.click(overflowTrigger(container))
fireEvent.click(menu(container).getAllByRole('button', { name: 'Carry-on', hidden: true })[0])
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
})
it('FE-W5ROW-053b: clearing the menu weight stores null', async () => {
let body: Record<string, unknown> | null = null
server.use(
http.put('/api/trips/1/packing/1', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>
return HttpResponse.json({ item: buildPackingItem({ id: 1 }) })
}),
)
const { container } = setup({
item: buildPackingItem({ id: 1, name: 'Tent', weight_grams: 400 } as Partial<PackingItem>),
bagTrackingEnabled: true,
bags: BAGS,
})
fireEvent.click(overflowTrigger(container))
fireEvent.change(fieldOf(container, 'Total weight'), { target: { value: '' } })
await waitFor(() => expect(body).toMatchObject({ weight_grams: null }))
})
it('FE-W5ROW-054: the menu list submenu moves the item and can be collapsed again', async () => {
let body: Record<string, unknown> | null = null
server.use(
@@ -821,6 +964,18 @@ describe('ArtikelZeile — overflow menu', () => {
await waitFor(() => expect(body).toMatchObject({ category: 'Docs' }))
})
it('FE-W5ROW-054a: an uncategorized item marks the default list in the submenu', () => {
const { container } = setup({
item: buildPackingItem({ id: 1, name: 'Soap', category: null }),
categories: ['Other', 'Gear'],
})
fireEvent.click(overflowTrigger(container))
fireEvent.click(menuButton(container, 'Move to List'))
expect(menuButton(container, 'Other')).toHaveStyle({ background: 'var(--bg-tertiary)' })
expect(menuButton(container, 'Gear')).not.toHaveStyle({ background: 'var(--bg-tertiary)' })
})
it('FE-W5ROW-055: the menu carries its own share control', () => {
const onSetSharing = vi.fn()
const { container } = setup({
@@ -842,15 +997,13 @@ describe('ArtikelZeile — overflow menu', () => {
})
it('FE-W5ROW-056: menu entries highlight on hover, the active one stays put', () => {
const { container } = setup({
item: buildPackingItem({ id: 1, name: 'Tent', category: 'Gear', bag_id: 8 } as Partial<PackingItem>),
bagTrackingEnabled: true,
bags: BAGS,
})
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS })
fireEvent.click(overflowTrigger(container))
const active = screen.getByRole('button', { name: 'Trolley' })
const plain = screen.getByRole('button', { name: 'Carry-on' })
const danger = screen.getByText('Delete').closest('button')!
fireEvent.click(menuButton(container, 'Move to List'))
// "Gear" is the item's current list, so its submenu entry renders as active.
const active = menuButton(container, 'Gear')
const plain = menuButton(container, 'Carry-on')
const danger = menu(container).getByText('Delete').closest('button')!
fireEvent.mouseEnter(plain)
expect(plain.style.background).toBe('var(--bg-tertiary)')
@@ -1,4 +1,4 @@
// FE-W5HOOK-001 to FE-W5HOOK-047
// FE-W5HOOK-001 to FE-W5HOOK-058
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { ChangeEvent, ReactNode } from 'react'
import { http, HttpResponse } from 'msw'
@@ -1,6 +1,6 @@
// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-079
import React from 'react';
import { render, screen, waitFor, within, act } from '../../../tests/helpers/render';
import { fireEvent, render, screen, waitFor, within, act } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -1436,3 +1436,397 @@ describe('DayDetailPanel', () => {
});
});
// FE-W5DDP-001 to FE-W5DDP-013 — the remaining formatting, reservation-row and
// hotel-picker branches of the day panel.
describe('DayDetailPanel remaining branches', () => {
const hotel = (overrides: Record<string, unknown> = {}) => ({
id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: null,
...overrides,
});
it('FE-W5DDP-001: reservation times follow the 12h preference around midnight and noon', async () => {
seedStore(useSettingsStore, { settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false } });
render(
<DayDetailPanel
{...defaultProps}
reservations={[
buildReservation({ id: 9, type: 'event', title: 'Night Show', day_id: 1, reservation_time: '00:15', reservation_end_time: '12:45', status: 'confirmed' }),
buildReservation({ id: 10, type: 'tour', title: 'Afternoon Tour', day_id: 1, reservation_time: '14:30', reservation_end_time: null, status: 'pending' }),
]}
/>,
);
expect(await screen.findByText('12:15 AM – 12:45 PM')).toBeInTheDocument();
expect(screen.getByText('2:30 PM')).toBeInTheDocument();
});
it('FE-W5DDP-002: a reservation without any time shows no time span', async () => {
render(
<DayDetailPanel
{...defaultProps}
reservations={[buildReservation({ id: 9, type: 'event', title: 'Open Ticket', day_id: 1, reservation_time: null, reservation_end_time: null })]}
/>,
);
const row = (await screen.findByText('Open Ticket')).closest('div[style*="border-radius: 8px"]') as HTMLElement;
expect(row.textContent).toBe('Open Ticket');
});
it('FE-W5DDP-003: an unknown reservation type falls back to the generic icon and names its place', async () => {
const place = buildPlace({ id: 10, name: 'Opera House' });
render(
<DayDetailPanel
{...defaultProps}
assignments={{ '1': [{ id: 77, day_id: 1, place }] as never }}
reservations={[
buildReservation({ id: 9, type: 'submarine', title: 'Deep Dive', assignment_id: 77, day_id: null }),
buildReservation({ id: 11, type: 'hotel', title: 'Hidden Hotel', day_id: 1 }),
buildReservation({ id: 12, type: 'flight', title: 'Other Day Flight', day_id: 2 }),
]}
/>,
);
const row = (await screen.findByText('Deep Dive')).closest('div[style*="border-radius: 8px"]') as HTMLElement;
expect(row.querySelector('svg')?.getAttribute('class')).toMatch(/file-text/);
expect(row).toHaveTextContent('Opera House');
// hotels are shown in the accommodation block, other days are not shown at all
expect(screen.queryByText('Hidden Hotel')).not.toBeInTheDocument();
expect(screen.queryByText('Other Day Flight')).not.toBeInTheDocument();
});
it('FE-W5DDP-004: a hotel photo, a check-in window and a confirmation code all render', async () => {
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({ accommodations: [hotel({ place_image: '/uploads/places/hotel.jpg', check_in_end: '18:00', confirmation: 'ABC123' })] }),
),
);
render(<DayDetailPanel {...defaultProps} />);
expect(await screen.findByText('14:00 – 18:00')).toBeInTheDocument();
expect(screen.getByText('ABC123')).toBeInTheDocument();
expect(document.querySelector('img[src="/uploads/places/hotel.jpg"]')).not.toBeNull();
});
it('FE-W5DDP-005: a blurred booking code unblurs on hover and toggles on click', async () => {
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: true } });
server.use(
http.get('/api/trips/1/accommodations', () => HttpResponse.json({ accommodations: [hotel({ accommodation_id: 1 })] })),
);
render(
<DayDetailPanel
{...defaultProps}
reservations={[buildReservation({ id: 40, type: 'hotel', title: 'Grand Hotel Booking', accommodation_id: 1, status: 'confirmed', confirmation_number: 'XY99' })]}
/>,
);
const code = await screen.findByText('#XY99');
expect(code.style.filter).toBe('blur(4px)');
fireEvent.mouseEnter(code);
expect(code.style.filter).toBe('none');
fireEvent.mouseLeave(code);
expect(code.style.filter).toBe('blur(4px)');
fireEvent.click(code);
expect(code.style.filter).toBe('none');
fireEvent.click(code);
expect(code.style.filter).toBe('blur(4px)');
});
it('FE-W5DDP-006: saving the edit picker updates the accommodation and reloads the list', async () => {
const user = userEvent.setup();
const onAccommodationChange = vi.fn();
let updateBody: Record<string, unknown> | null = null;
let listCalls = 0;
server.use(
http.get('/api/trips/1/accommodations', () => {
listCalls += 1;
return HttpResponse.json({
accommodations: [hotel(listCalls > 1 ? { place_name: 'Reloaded Hotel' } : {})],
});
}),
http.put('/api/trips/1/accommodations/1', async ({ request }) => {
updateBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ accommodation: hotel() });
}),
);
render(<DayDetailPanel {...defaultProps} places={[buildPlace({ id: 5, name: 'Grand Hotel' })]} onAccommodationChange={onAccommodationChange} />);
await screen.findByText('Grand Hotel');
await user.click(screen.getAllByRole('button').find(b => b.querySelector('svg')?.getAttribute('class')?.includes('pencil')) as HTMLElement);
await user.click(await screen.findByText(/^Save$/i));
await waitFor(() => expect(updateBody).not.toBeNull());
expect(updateBody).toMatchObject({ place_id: 5, start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: null });
await waitFor(() => expect(screen.getByText('Reloaded Hotel')).toBeInTheDocument());
expect(onAccommodationChange).toHaveBeenCalled();
});
it('FE-W5DDP-007: the hotel picker filters the place list by category', async () => {
const user = userEvent.setup();
const museums = { id: 3, name: 'Museums', color: '#ff0000' };
render(
<DayDetailPanel
{...defaultProps}
categories={[museums] as never}
places={[
buildPlace({ id: 10, name: 'Maison Blanche', category_id: 3 }),
buildPlace({ id: 11, name: 'Chez Nous', category_id: 4 }),
]}
/>,
);
await user.click(await screen.findByText(/Add accommodation/i));
expect(screen.getByRole('button', { name: /Chez Nous/ })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Museums' }));
expect(screen.queryByRole('button', { name: /Chez Nous/ })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /Maison Blanche/ })).toBeInTheDocument();
const filterRow = screen.getByRole('button', { name: 'Museums' }).parentElement as HTMLElement;
await user.click(within(filterRow).getByRole('button', { name: /^All$/ }));
expect(screen.getByRole('button', { name: /Chez Nous/ })).toBeInTheDocument();
});
it('FE-W5DDP-008: the day-range pickers badge dated days, titled days and nothing else', async () => {
const user = userEvent.setup();
const dated = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: 'Day in Paris' });
const titled = buildDay({ id: 2, trip_id: 1, date: null, title: 'Free Day' });
const bare = buildDay({ id: 3, trip_id: 1, date: null, title: null });
render(<DayDetailPanel {...defaultProps} day={dated} days={[dated, titled, bare]} places={[buildPlace({ id: 10, name: 'Maison Blanche' })]} />);
await user.click(await screen.findByText(/Add accommodation/i));
// open the "from" select — the option rows carry the badges
const range = screen.getByText('Apply to days').parentElement as HTMLElement;
await user.click(within(range).getAllByRole('button')[0]);
expect(screen.getAllByText('Jun 15').length).toBeGreaterThanOrEqual(1);
const freeDay = screen.getAllByRole('button', { name: /Free Day/ });
expect(freeDay.some(b => b.textContent?.includes('Day 2'))).toBe(true);
expect(screen.getAllByRole('button', { name: /^Day 3$/ }).length).toBeGreaterThanOrEqual(1);
});
it('FE-W5DDP-009: a day without a title falls back to its position and hides the pencil', () => {
const bare = buildDay({ id: 2, trip_id: 1, date: null, title: null });
render(<DayDetailPanel {...defaultProps} day={bare} days={[defaultProps.day, bare]} />);
expect(screen.getByText('Day 2')).toBeInTheDocument();
expect(screen.queryByLabelText('Edit')).not.toBeInTheDocument();
});
it('FE-W5DDP-010: renaming a day that is not in the list still commits the draft', async () => {
const user = userEvent.setup();
const onUpdateDayTitle = vi.fn();
const orphan = buildDay({ id: 9, trip_id: 1, date: null, title: null });
render(<DayDetailPanel {...defaultProps} day={orphan} days={[]} onUpdateDayTitle={onUpdateDayTitle} />);
expect(screen.getByText('Day ?')).toBeInTheDocument();
await user.click(screen.getByLabelText('Edit'));
const input = await screen.findByPlaceholderText('Day ?');
await user.type(input, 'Named Later');
fireEvent.blur(input);
expect(onUpdateDayTitle).toHaveBeenCalledWith(9, 'Named Later');
});
it('FE-W5DDP-011: an unmapped weather condition falls back to the cloud icon', async () => {
server.use(
http.get('/api/weather/detailed', () =>
HttpResponse.json({
main: 'Sandstorm', temp: 30, description: 'blowing sand', type: 'forecast',
hourly: [{ hour: 9, main: 'Sandstorm', temp: 28, precipitation_probability: 0 }],
}),
),
);
render(<DayDetailPanel {...defaultProps} lat={48.85} lng={2.35} />);
expect(await screen.findByText('blowing sand')).toBeInTheDocument();
expect(document.querySelectorAll('svg[class*="lucide-cloud"]').length).toBeGreaterThanOrEqual(2);
});
it('FE-W5DDP-012: an ISO check-in timestamp is rendered in local time', async () => {
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({ accommodations: [hotel({ check_in: '2025-06-15T14:00', check_out: null })] }),
),
);
render(<DayDetailPanel {...defaultProps} />);
expect(await screen.findByText('14:00')).toBeInTheDocument();
});
it('FE-W5DDP-013: the collapsed header keeps the title and the date on one line', async () => {
const user = userEvent.setup();
const onToggleCollapse = vi.fn();
const { rerender } = render(<DayDetailPanel {...defaultProps} collapsed={false} onToggleCollapse={onToggleCollapse} />);
await user.click(screen.getByTitle('Collapse'));
expect(onToggleCollapse).toHaveBeenCalled();
rerender(<DayDetailPanel {...defaultProps} collapsed onToggleCollapse={onToggleCollapse} />);
const header = screen.getByText('Day in Paris').closest('div') as HTMLElement;
expect(header).toHaveTextContent(/Sunday, June 15/);
expect(screen.getByTitle('Expand')).toBeInTheDocument();
});
});
// FE-W5DDP-014 to FE-W5DDP-020
describe('DayDetailPanel remaining branches, part two', () => {
const hotel = (overrides: Record<string, unknown> = {}) => ({
id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: null,
...overrides,
});
it('FE-W5DDP-014: an unblurred booking code stays readable and pending bookings look different', async () => {
server.use(
http.get('/api/trips/1/accommodations', () => HttpResponse.json({ accommodations: [hotel()] })),
);
render(
<DayDetailPanel
{...defaultProps}
reservations={[buildReservation({ id: 40, type: 'hotel', title: 'Grand Hotel Booking', accommodation_id: 1, status: 'pending', confirmation_number: 'XY99' })]}
/>,
);
const code = await screen.findByText('#XY99');
expect(code.style.filter).toBe('none');
expect(code.style.cursor).toBe('default');
fireEvent.mouseEnter(code);
fireEvent.click(code);
fireEvent.mouseLeave(code);
expect(code.style.filter).toBe('none');
expect(screen.getByText('Pending')).toBeInTheDocument();
});
it('FE-W5DDP-015: mobile mode lifts the panel and works without a ResizeObserver', () => {
vi.stubGlobal('ResizeObserver', undefined);
try {
const { container } = render(<DayDetailPanel {...defaultProps} mobile collapsed />);
const panel = container.firstElementChild as HTMLElement;
expect(panel.style.zIndex).toBe('10000');
expect(document.documentElement.style.getPropertyValue('--day-panel-h')).not.toBe('');
} finally {
vi.unstubAllGlobals();
}
});
it('FE-W5DDP-016: with coordinates the reservations block gets its own divider', async () => {
render(
<DayDetailPanel
{...defaultProps}
lat={48.85}
lng={2.35}
reservations={[buildReservation({ id: 9, type: 'event', title: 'Late Entry', day_id: 1, reservation_time: null, reservation_end_time: '22:30' })]}
/>,
);
expect(await screen.findByText('Late Entry')).toBeInTheDocument();
expect(screen.getByText('– 22:30')).toBeInTheDocument();
});
it('FE-W5DDP-017: moving the start day past the end day drags the end day with it', async () => {
const user = userEvent.setup();
const d1 = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: 'Day in Paris' });
const d2 = buildDay({ id: 2, trip_id: 1, date: '2025-06-16', title: 'Day Two' });
render(<DayDetailPanel {...defaultProps} day={d1} days={[d1, d2]} places={[buildPlace({ id: 10, name: 'Maison Blanche' })]} />);
await user.click(await screen.findByText(/Add accommodation/i));
const range = screen.getByText('Apply to days').parentElement as HTMLElement;
const [fromSelect, toSelect] = within(range).getAllByRole('button');
await user.click(fromSelect);
await user.click(screen.getAllByRole('button', { name: /Day Two/ })[0]);
expect(within(range).getAllByRole('button')[1]).toHaveTextContent('Day Two');
await user.click(within(range).getAllByRole('button')[1] === toSelect ? toSelect : within(range).getAllByRole('button')[1]);
await user.click(screen.getAllByRole('button', { name: /Day in Paris/ })[0]);
expect(within(range).getAllByRole('button')[0]).toHaveTextContent('Day in Paris');
// moving each end back inside the range leaves the other one alone
await user.click(within(range).getAllByRole('button')[1]);
await user.click(screen.getAllByRole('button', { name: /Day Two/ })[0]);
expect(within(range).getAllByRole('button')[0]).toHaveTextContent('Day in Paris');
await user.click(within(range).getAllByRole('button')[0]);
await user.click(screen.getAllByRole('button', { name: /Day in Paris/ })[0]);
expect(within(range).getAllByRole('button')[1]).toHaveTextContent('Day Two');
});
it('FE-W5DDP-018: the picked place keeps its highlight on hover and shows its photo', async () => {
const user = userEvent.setup();
const withPhoto = buildPlace({ id: 10, name: 'Maison Blanche', image_url: '/uploads/places/mb.jpg', address: null });
const other = buildPlace({ id: 11, name: 'Chez Nous' });
render(<DayDetailPanel {...defaultProps} places={[withPhoto, other]} />);
await user.click(await screen.findByText(/Add accommodation/i));
const picked = screen.getByRole('button', { name: /Maison Blanche/ });
await user.click(picked);
expect(picked.style.background).toBe('var(--bg-hover)');
fireEvent.mouseEnter(picked);
fireEvent.mouseLeave(picked);
expect(picked.style.background).toBe('var(--bg-hover)');
expect(document.querySelector('img[src="/uploads/places/mb.jpg"]')).not.toBeNull();
});
it('FE-W5DDP-019: a category without a colour falls back to the default filter highlight', async () => {
const user = userEvent.setup();
render(
<DayDetailPanel
{...defaultProps}
categories={[{ id: 3, name: 'Uncoloured', color: null }] as never}
places={[buildPlace({ id: 10, name: 'Maison Blanche', category_id: 3 })]}
/>,
);
await user.click(await screen.findByText(/Add accommodation/i));
const chip = screen.getByRole('button', { name: 'Uncoloured' });
await user.click(chip);
expect(chip.style.background).toBe('var(--text-primary)');
});
it('FE-W5DDP-022: a plugin column for this day is appended above the reservations', async () => {
server.use(
http.get('/api/view-contributions/day/1', () =>
HttpResponse.json({ contributions: [{ kind: 'column', pluginId: 'sun', entityId: 1, label: 'Daylight', value: '15h 20m', tone: 'default' }] }),
),
);
render(<DayDetailPanel {...defaultProps} />);
expect(await screen.findByText('15h 20m')).toBeInTheDocument();
});
it('FE-W5DDP-021: a collapsed untitled day falls back to its position, or to ? when it has none', () => {
const bare = buildDay({ id: 2, trip_id: 1, date: null, title: null });
const { unmount } = render(<DayDetailPanel {...defaultProps} day={bare} days={[defaultProps.day, bare]} collapsed />);
expect(screen.getByText('Day 2')).toBeInTheDocument();
unmount();
render(<DayDetailPanel {...defaultProps} day={bare} days={[]} collapsed />);
expect(screen.getByText('Day ?')).toBeInTheDocument();
});
it('FE-W5DDP-020: an empty reload after the edit-save clears the accommodation', async () => {
const user = userEvent.setup();
let listCalls = 0;
server.use(
http.get('/api/trips/1/accommodations', () => {
listCalls += 1;
return listCalls === 1 ? HttpResponse.json({ accommodations: [hotel()] }) : HttpResponse.json({});
}),
http.put('/api/trips/1/accommodations/1', () => HttpResponse.json({ accommodation: hotel() })),
);
render(<DayDetailPanel {...defaultProps} places={[buildPlace({ id: 5, name: 'Grand Hotel' })]} />);
await screen.findByText('Grand Hotel');
await user.click(screen.getAllByRole('button').find(b => b.querySelector('svg')?.getAttribute('class')?.includes('pencil')) as HTMLElement);
await user.click(await screen.findByText(/^Save$/i));
expect(await screen.findByText(/Add accommodation/i)).toBeInTheDocument();
});
});
@@ -3667,3 +3667,83 @@ describe('DayPlanSidebar', () => {
expect(row.style.opacity).toBe('1')
})
})
// FE-W5DPS-001 to FE-W5DPS-006 — booking subtitles, the collections entry in the
// place menu and the accommodation ordering on a hotel-change day.
describe('DayPlanSidebar remaining branches', () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const renderWith = (reservations: Reservation[], overrides = {}) => {
seedStore(useTripStore, { reservations })
return render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations, ...overrides })} />)
}
it('FE-W5DPS-001: a flight without an airline still shows its flight number', async () => {
renderWith([buildReservation({
id: 601, type: 'flight', title: 'Hop', day_id: 10,
reservation_time: '2025-06-01T09:00:00', metadata: JSON.stringify({ flight_number: 'AF900' }),
}) as Reservation])
expect(await screen.findByText('AF900')).toBeInTheDocument()
})
it('FE-W5DPS-002: a train shows its train number', async () => {
renderWith([buildReservation({
id: 602, type: 'train', title: 'ICE', day_id: 10,
reservation_time: '2025-06-01T09:00:00', metadata: JSON.stringify({ train_number: 'ICE 599' }),
}) as Reservation])
expect(await screen.findByText('ICE 599')).toBeInTheDocument()
})
it('FE-W5DPS-003: a booking without usable metadata gets no subtitle', async () => {
renderWith([buildReservation({
id: 603, type: 'bus', title: 'Shuttle', day_id: 10,
reservation_time: '2025-06-01T09:00:00', metadata: JSON.stringify({ seat: '4B' }),
}) as Reservation])
const row = cardRow(await screen.findByText('Shuttle'))
expect(row).not.toHaveTextContent('4B')
})
it('FE-W5DPS-005: the place menu offers Save to collection only with the addon on', async () => {
const user = userEvent.setup()
const place = buildPlace({ id: 1, name: 'Louvre' })
const assignments = { '10': [buildAssignment({ id: 11, day_id: 10, order_index: 0, place })] }
const { unmount } = render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />)
fireEvent.contextMenu(dragRow(screen.getByText('Louvre')))
expect(contextMenu().queryByText(/save to/i)).not.toBeInTheDocument()
unmount()
const { useAddonStore } = await import('../../store/addonStore')
seedStore(useAddonStore, {
addons: [{ id: 'collections', name: 'Collections', type: 'trip', icon: '', enabled: true }],
loaded: true,
})
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />)
fireEvent.contextMenu(dragRow(screen.getByText('Louvre')))
await user.click(contextMenu().getByText(/save to/i))
const { useSaveToCollectionStore } = await import('../../store/saveToCollectionStore')
expect(useSaveToCollectionStore.getState().target).not.toBeNull()
})
it('FE-W5DPS-006: a hotel-change day lists the departing stay before the arriving one', async () => {
const dayBefore = buildDay({ id: 9, date: '2025-05-31', title: 'Day 0' })
const dayAfter = buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' })
const accommodations = [
{ id: 1, place_id: 5, place_name: 'Arriving Inn', start_day_id: 10, end_day_id: 11 },
{ id: 2, place_id: 6, place_name: 'Departing Inn', start_day_id: 9, end_day_id: 10 },
] as unknown as Accommodation[]
render(<DayPlanSidebar {...makeDefaultProps({
days: [dayBefore, day, dayAfter], accommodations, selectedDayId: 10,
})} />)
const body = document.body.textContent ?? ''
expect(body.indexOf('Departing Inn')).toBeGreaterThan(-1)
expect(body.indexOf('Departing Inn')).toBeLessThan(body.indexOf('Arriving Inn'))
})
})
@@ -1115,3 +1115,218 @@ describe('PlaceFormModal', () => {
expect(warning).not.toHaveTextContent('Timeless Event');
});
});
// FE-W5PFM-001 to FE-W5PFM-014 — the remaining prefill, response-shape and
// error branches of the place form.
describe('PlaceFormModal remaining branches', () => {
it('FE-W5PFM-001: an existing place with empty fields opens on the defaults', () => {
const place = buildPlace({
name: null, description: null, address: null, lat: null, lng: null,
category_id: null, notes: null, transport_mode: null, website: null,
});
render(<PlaceFormModal {...defaultProps} place={place} />);
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('');
expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue('');
expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue('');
expect(screen.getByPlaceholderText('https://...')).toHaveValue('');
});
it('FE-W5PFM-002: prefilled coordinates without a name only fill the coordinates', () => {
render(<PlaceFormModal {...defaultProps} prefillCoords={{ lat: 48.8584, lng: 2.2945 }} />);
expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue('48.8584');
expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue('2.2945');
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('');
expect(screen.getByPlaceholderText(/Street, City, Country/i)).toHaveValue('');
});
it('FE-W5PFM-003: an autocomplete response without a suggestions array shows no dropdown', async () => {
const user = userEvent.setup();
server.use(http.post('/api/maps/autocomplete', () => HttpResponse.json({})));
render(<PlaceFormModal {...defaultProps} />);
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
expect(screen.queryByText('Eiffel Tower, Paris')).not.toBeInTheDocument();
});
it('FE-W5PFM-004: an aborted autocomplete is swallowed without logging', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const user = userEvent.setup();
const gate: { release?: () => void } = {};
let seen = 0;
server.use(
http.post('/api/maps/autocomplete', async () => {
seen += 1;
if (seen === 1) await new Promise<void>(res => { gate.release = res; });
return HttpResponse.json({
suggestions: [{ placeId: 'node:1', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
});
}),
);
render(<PlaceFormModal {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Eiff');
await waitFor(() => expect(seen).toBe(1));
// The second query aborts the still-open first one
await user.type(searchInput, 'el tower');
await waitFor(() => expect(seen).toBe(2));
gate.release?.();
expect(await screen.findByText('Paris, France')).toBeInTheDocument();
expect(screen.getAllByText('Eiffel Tower')).toHaveLength(1);
expect(consoleError).not.toHaveBeenCalledWith('Autocomplete failed:', expect.anything());
consoleError.mockRestore();
});
it('FE-W5PFM-005: a resolved URL without a name or address keeps the form values', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/maps/resolve-url', () => HttpResponse.json({ lat: 48.853, lng: 2.3499 })),
);
render(<PlaceFormModal {...defaultProps} />);
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Kept Name');
await user.type(screen.getByPlaceholderText('Search places...'), 'https://maps.google.com/maps?q=x');
await user.keyboard('{Enter}');
expect(await screen.findByDisplayValue('48.853')).toBeInTheDocument();
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('Kept Name');
});
it('FE-W5PFM-006: a search response without a places array renders no results', async () => {
const user = userEvent.setup();
server.use(http.post('/api/maps/search', () => HttpResponse.json({})));
render(<PlaceFormModal {...defaultProps} />);
await user.type(screen.getByPlaceholderText('Search places...'), 'nowhere');
await user.keyboard('{Enter}');
await waitFor(() => expect(screen.getByPlaceholderText('Search places...')).toHaveValue('nowhere'));
expect(screen.queryByRole('button', { name: /nowhere/i })).not.toBeInTheDocument();
});
it('FE-W5PFM-007: picking a bare search result leaves the typed fields alone', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Bare Result' }] })),
);
render(<PlaceFormModal {...defaultProps} />);
await user.type(screen.getByPlaceholderText(/Street, City, Country/i), 'Rue de Rivoli');
await user.type(screen.getByPlaceholderText('Search places...'), 'bare');
await user.keyboard('{Enter}');
await user.click(await screen.findByText('Bare Result'));
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('Bare Result');
expect(screen.getByPlaceholderText(/Street, City, Country/i)).toHaveValue('Rue de Rivoli');
expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue('');
});
it('FE-W5PFM-008: a place sharing only the name is flagged as a duplicate', async () => {
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ name: 'Louvre', lat: null, lng: null })] });
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'louvre');
await user.click(screen.getByRole('button', { name: /^Add$/i }));
expect(onSave).not.toHaveBeenCalled();
expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Louvre'), 'warning', undefined);
// The second click is the explicit confirmation
await user.click(screen.getByRole('button', { name: /Add anyway/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
delete window.__addToast;
});
it('FE-W5PFM-009: a place sharing only the coordinates is flagged as a duplicate', async () => {
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ name: null, lat: 48.8584, lng: 2.2945 })] });
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Tour Eiffel');
await user.type(screen.getByPlaceholderText(/Latitude/i), '48.85841');
await user.type(screen.getByPlaceholderText(/Longitude/i), '2.29451');
await user.click(screen.getByRole('button', { name: /^Add$/i }));
expect(onSave).not.toHaveBeenCalled();
// the existing place has no name, so the warning falls back to the typed one
expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Tour Eiffel'), 'warning', undefined);
delete window.__addToast;
});
it('FE-W5PFM-010: a rejected save without an Error surfaces the generic message', async () => {
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
const onSave = vi.fn().mockRejectedValue('nope');
const onClose = vi.fn();
render(<PlaceFormModal {...defaultProps} onSave={onSave} onClose={onClose} />);
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Louvre');
await user.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to save', 'error', undefined));
expect(onClose).not.toHaveBeenCalled();
delete window.__addToast;
});
it('FE-W5PFM-011: pasting a coordinate pair into latitude fills both fields', () => {
render(<PlaceFormModal {...defaultProps} />);
const lat = screen.getByPlaceholderText(/Latitude/i);
fireEvent.paste(lat, { clipboardData: { getData: () => ' 48.8584, 2.2945 ' } });
expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue('48.8584');
expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue('2.2945');
});
it('FE-W5PFM-012: pasting anything else into latitude is left to the input', () => {
render(<PlaceFormModal {...defaultProps} />);
const lat = screen.getByPlaceholderText(/Latitude/i);
fireEvent.paste(lat, { clipboardData: { getData: () => 'not a coordinate' } });
expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue('');
});
it('FE-W5PFM-013: a missing category list still renders the no-category option', () => {
render(<PlaceFormModal {...defaultProps} categories={null} />);
expect(screen.getByText(/No category/i)).toBeInTheDocument();
});
it('FE-W5PFM-014: an open-ended assignment collapses to a point in time', () => {
const current = buildPlace({ name: 'My Event', place_time: '11:30', end_time: '' });
const spanning = buildPlace({ name: 'Spanning Event', place_time: '11:00', end_time: '12:00' });
const earlier = buildPlace({ name: 'Earlier Event', place_time: '11:00', end_time: '' });
render(
<PlaceFormModal
{...defaultProps}
place={current}
assignmentId={10}
dayAssignments={[
buildAssignment({ id: 10, day_id: 5, place: current }),
buildAssignment({ id: 20, day_id: 5, place: spanning }),
buildAssignment({ id: 30, day_id: 5, place: earlier }),
]}
/>,
);
const warning = screen.getByText(/Time overlap with:/i).closest('div') as HTMLElement;
expect(warning).toHaveTextContent('Spanning Event');
expect(warning).not.toHaveTextContent('Earlier Event');
});
});
@@ -412,6 +412,45 @@ describe('PluginFrame', () => {
expect(geo.clearWatch).not.toHaveBeenCalled();
expect(answerFor(posted, 'g11')).toMatchObject({ cleared: true });
});
it('FE-PLUGINS-FRAME-056: a fix arriving after clear + revoke is dropped without a second clearWatch', () => {
let tick: ((p: unknown) => void) | null = null;
let boom: ((e: unknown) => void) | null = null;
geo.watchPosition.mockImplementation((ok: (p: unknown) => void, fail: (e: unknown) => void) => {
tick = ok; boom = fail; return 7;
});
const { iframe, posted } = mount(true);
fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g12', action: 'watch' });
fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g13', action: 'clear' });
act(() => grant(false));
geo.clearWatch.mockClear();
const before = posted.length;
// Stale callbacks from the released watch: nothing is posted, and the
// already-cleared watch id is not cleared a second time.
act(() => tick!({ coords: { latitude: 1, longitude: 2, accuracy: 5, heading: null, speed: null }, timestamp: 1 }));
act(() => boom!({ code: 1, PERMISSION_DENIED: 1, TIMEOUT: 3 }));
expect(posted.length).toBe(before);
expect(geo.clearWatch).not.toHaveBeenCalled();
});
it('FE-PLUGINS-FRAME-057: a position resolved after the frame navigated is never delivered', () => {
let ok: ((p: unknown) => void) | null = null;
let fail: ((e: unknown) => void) | null = null;
geo.getCurrentPosition.mockImplementation((o: (p: unknown) => void, f: (e: unknown) => void) => { ok = o; fail = f; });
const { iframe, posted } = mount(true);
fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g14' });
act(() => { fireEvent.load(iframe); });
act(() => { fireEvent.load(iframe); }); // self-navigation while the fix was pending
act(() => ok!({ coords: { latitude: 1, longitude: 2, accuracy: 5, heading: null, speed: null }, timestamp: 1 }));
act(() => fail!({ code: 1, PERMISSION_DENIED: 1, TIMEOUT: 3 }));
expect(posted.filter((m) => m.type === 'trek:geolocation:result')).toHaveLength(0);
});
});
it('FE-PLUGINS-FRAME-020: brokered plugin session state round-trips through host sessionStorage', () => {
@@ -478,4 +517,346 @@ describe('PluginFrame', () => {
}
expect(set('too-many', 'key-32', 32)).toMatchObject({ type: 'trek:error', code: 'SESSION_KEY_LIMIT' });
});
it('FE-PLUGINS-FRAME-029: session:remove drops only the addressed key', () => {
const { iframe, posted } = mountFrame();
fromFrame(iframe, { type: 'trek:session:set', requestId: 'a', key: 'keep', value: 1 });
fromFrame(iframe, { type: 'trek:session:set', requestId: 'b', key: 'drop', value: 2 });
fromFrame(iframe, { type: 'trek:session:remove', requestId: 'c', key: 'drop' });
fromFrame(iframe, { type: 'trek:session:get', requestId: 'd', key: 'drop' });
fromFrame(iframe, { type: 'trek:session:get', requestId: 'e', key: 'keep' });
expect(answerFor(posted, 'c')).toMatchObject({ type: 'trek:response' });
expect(answerFor(posted, 'd')).toMatchObject({ type: 'trek:response', data: undefined });
expect(answerFor(posted, 'e')).toMatchObject({ type: 'trek:response', data: 1 });
});
it('FE-PLUGINS-FRAME-030: a value that is not JSON-serialisable is refused', () => {
const { iframe, posted } = mountFrame();
fromFrame(iframe, { type: 'trek:session:set', requestId: 's1', key: 'k', value: undefined });
expect(answerFor(posted, 's1')).toMatchObject({ type: 'trek:error', code: 'SESSION_INVALID_VALUE' });
expect(sessionStorage.length).toBe(0);
});
it('FE-PLUGINS-FRAME-031: session requests without a requestId are dropped silently', () => {
const { iframe, posted } = mountFrame();
fromFrame(iframe, { type: 'trek:session:get', key: 'k' });
fromFrame(iframe, { type: 'trek:session:set', key: 'k', value: 1 });
fromFrame(iframe, { type: 'trek:session:remove', key: 'k' });
fromFrame(iframe, { type: 'trek:session:clear' });
expect(posted).toHaveLength(0);
});
it('FE-PLUGINS-FRAME-032: unreadable stored JSON surfaces as SESSION_STORAGE_ERROR', () => {
sessionStorage.setItem('trek:plugin-session:7:demo:plugin:broken', '{not json');
const { iframe, posted } = mountFrame();
fromFrame(iframe, { type: 'trek:session:get', requestId: 's1', key: 'broken' });
expect(answerFor(posted, 's1')).toMatchObject({ type: 'trek:error', code: 'SESSION_STORAGE_ERROR' });
expect(String(answerFor(posted, 's1')!.message)).not.toBe('session storage failed');
});
it('FE-PLUGINS-FRAME-033: a non-Error storage failure still yields a message', () => {
const removeItem = vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { throw 'nope'; });
try {
const { iframe, posted } = mountFrame();
fromFrame(iframe, { type: 'trek:session:remove', requestId: 's1', key: 'k' });
expect(answerFor(posted, 's1')).toMatchObject({
type: 'trek:error',
code: 'SESSION_STORAGE_ERROR',
message: 'session storage failed',
});
} finally {
removeItem.mockRestore();
}
});
describe('trek:session:clear', () => {
it('FE-PLUGINS-FRAME-034: wipes only this plugin scope, leaving TREK and trip keys alone', () => {
const { iframe, posted } = mountFrame({ tripId: '42' });
fromFrame(iframe, { type: 'trek:session:set', requestId: 'a', key: 'x', value: 1 });
fromFrame(iframe, { type: 'trek:session:set', requestId: 'b', key: 'y', value: 2 });
fromFrame(iframe, { type: 'trek:session:set', requestId: 'c', key: 'z', value: 3, scope: 'trip' });
sessionStorage.setItem('trek:some-app-state', 'keep me');
fromFrame(iframe, { type: 'trek:session:clear', requestId: 'clr' });
expect(answerFor(posted, 'clr')).toMatchObject({ type: 'trek:response', data: undefined });
fromFrame(iframe, { type: 'trek:session:get', requestId: 'g1', key: 'x' });
fromFrame(iframe, { type: 'trek:session:get', requestId: 'g2', key: 'z', scope: 'trip' });
expect(answerFor(posted, 'g1')).toMatchObject({ data: undefined });
expect(answerFor(posted, 'g2')).toMatchObject({ data: 3 });
expect(sessionStorage.getItem('trek:some-app-state')).toBe('keep me');
});
it('FE-PLUGINS-FRAME-035: trip scope without a trip context is refused', () => {
const { iframe, posted } = mountFrame();
fromFrame(iframe, { type: 'trek:session:clear', requestId: 'clr', scope: 'trip' });
expect(answerFor(posted, 'clr')).toMatchObject({ type: 'trek:error', code: 'NO_TRIP_CONTEXT' });
});
it('FE-PLUGINS-FRAME-036: a storage failure is reported with the underlying message', () => {
const removeItem = vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
throw new Error('quota');
});
try {
sessionStorage.setItem('trek:plugin-session:7:demo:plugin:x', '1');
const { iframe, posted } = mountFrame();
fromFrame(iframe, { type: 'trek:session:clear', requestId: 'clr' });
expect(answerFor(posted, 'clr')).toMatchObject({
type: 'trek:error',
code: 'SESSION_STORAGE_ERROR',
message: 'quota',
});
} finally {
removeItem.mockRestore();
}
});
it('FE-PLUGINS-FRAME-058: a non-Error storage failure falls back to a generic message', () => {
const removeItem = vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { throw 'nope'; });
try {
sessionStorage.setItem('trek:plugin-session:7:demo:plugin:x', '1');
const { iframe, posted } = mountFrame();
fromFrame(iframe, { type: 'trek:session:clear', requestId: 'clr' });
expect(answerFor(posted, 'clr')).toMatchObject({
type: 'trek:error',
code: 'SESSION_STORAGE_ERROR',
message: 'session storage failed',
});
} finally {
removeItem.mockRestore();
}
});
});
describe('message gating', () => {
it('FE-PLUGINS-FRAME-037: non-object payloads are ignored', () => {
const { iframe, posted } = mountFrame();
fromFrame(iframe, null);
fromFrame(iframe, 'trek:navigate');
expect(navigate).not.toHaveBeenCalled();
expect(posted).toHaveLength(0);
});
it('FE-PLUGINS-FRAME-038: a frame that navigated itself loses the bridge', () => {
const { iframe, posted } = mountFrame();
act(() => { fireEvent.load(iframe); });
act(() => { fireEvent.load(iframe); }); // self-navigation: second document
fromFrame(iframe, { type: 'trek:navigate', to: '/dashboard' });
fromFrame(iframe, { type: 'trek:context:request' });
expect(navigate).not.toHaveBeenCalled();
// Only the very first load delivered a context; nothing after that.
expect(posted.filter((m) => m.type === 'trek:context')).toHaveLength(1);
});
it('FE-PLUGINS-FRAME-039: a non-string navigation target is ignored', () => {
const { iframe } = mountFrame();
fromFrame(iframe, { type: 'trek:navigate', to: 42 });
expect(navigate).not.toHaveBeenCalled();
});
it('FE-PLUGINS-FRAME-040: an empty notification is dropped and an unknown level falls back to info', () => {
const { iframe } = mountFrame();
fromFrame(iframe, { type: 'trek:notify' });
fromFrame(iframe, { type: 'trek:notify', message: '' });
expect(Object.values(toast).some((f) => f.mock.calls.length > 0)).toBe(false);
fromFrame(iframe, { type: 'trek:notify', level: 'critical', message: 'fallback' });
expect(toast.info).toHaveBeenCalledWith('fallback');
});
it('FE-PLUGINS-FRAME-041: openExternal without a url is ignored', () => {
const open = vi.spyOn(window, 'open').mockReturnValue(null);
const { iframe } = mountFrame();
fromFrame(iframe, { type: 'trek:openExternal' });
expect(open).not.toHaveBeenCalled();
open.mockRestore();
});
});
describe('trek:confirm', () => {
it('FE-PLUGINS-FRAME-042: a request without a requestId never opens a dialog', () => {
const { iframe } = mountFrame();
fromFrame(iframe, { type: 'trek:confirm', message: 'Delete?' });
expect(screen.queryByText('Delete?')).toBeNull();
});
it('FE-PLUGINS-FRAME-043: the dialog title leads with the host-controlled plugin name', async () => {
const { iframe } = mountFrame({ title: 'Trip To-Dos' });
act(() => { fromFrame(iframe, { type: 'trek:confirm', requestId: 'c1', title: 'Really?', message: 'Wipe list' }); });
expect(await screen.findByText('Trip To-Dos — Really?')).toBeInTheDocument();
});
it('FE-PLUGINS-FRAME-044: a second confirm is refused while one is open, and cancel answers false', async () => {
const { iframe, posted } = mountFrame();
act(() => { fromFrame(iframe, { type: 'trek:confirm', requestId: 'c1', message: 'First', cancelLabel: 'No thanks' }); });
await screen.findByText('No thanks');
fromFrame(iframe, { type: 'trek:confirm', requestId: 'c2', message: 'Second' });
expect(answerFor(posted, 'c2')).toMatchObject({ type: 'trek:confirm:result', confirmed: false });
expect(screen.queryByText('Second')).toBeNull();
fireEvent.click(screen.getByText('No thanks'));
expect(answerFor(posted, 'c1')).toMatchObject({ type: 'trek:confirm:result', confirmed: false });
});
it('FE-PLUGINS-FRAME-045: non-string labels are dropped and danger defaults to true', async () => {
const { iframe } = mountFrame();
act(() => {
fromFrame(iframe, { type: 'trek:confirm', requestId: 'c1', title: 1, message: 2, confirmLabel: 3, cancelLabel: 4 });
});
// Falls back to the ConfirmDialog defaults, and the title is the plugin id.
expect(await screen.findByText('demo')).toBeInTheDocument();
});
});
describe('trek:invoke failures', () => {
it('FE-PLUGINS-FRAME-046: an HTTP failure is relayed with its status and message', async () => {
invoke.mockRejectedValueOnce(Object.assign(new Error('bad gateway'), { response: { status: 502 } }));
const { iframe, posted } = mountFrame();
fromFrame(iframe, { type: 'trek:invoke', requestId: 'r1', sub: '/x' });
await waitFor(() => expect(answerFor(posted, 'r1')).toBeTruthy());
expect(answerFor(posted, 'r1')).toMatchObject({ type: 'trek:error', code: 502, message: 'bad gateway' });
});
it('FE-PLUGINS-FRAME-047: a failure without status or message falls back to generic values', async () => {
invoke.mockRejectedValueOnce({});
const { iframe, posted } = mountFrame();
fromFrame(iframe, { type: 'trek:invoke', requestId: 'r2', sub: '/x' });
await waitFor(() => expect(answerFor(posted, 'r2')).toBeTruthy());
expect(answerFor(posted, 'r2')).toMatchObject({ type: 'trek:error', code: 'error', message: 'invoke failed' });
});
});
describe('context inputs', () => {
it('FE-PLUGINS-FRAME-048: mirrors dark mode, RTL, compact density and the resolved accent', () => {
const html = document.documentElement;
html.classList.add('dark');
html.setAttribute('dir', 'rtl');
html.dataset.scheme = 'indigo';
html.dataset.density = 'compact';
html.setAttribute('data-no-transparency', '');
html.setAttribute('data-reduce-motion', '');
html.style.setProperty('--accent', '#123456');
const { iframe, posted } = mountFrame();
fromFrame(iframe, { type: 'trek:context:request' });
const ctx = posted.find((m) => m.type === 'trek:context')!;
expect(ctx.theme).toBe('dark');
expect(ctx.dir).toBe('rtl');
expect(ctx.appearance).toMatchObject({
scheme: 'indigo',
density: 'compact',
noTransparency: true,
reducedMotion: true,
});
expect((ctx.tokens as Record<string, string>)['--accent']).toBe('#123456');
});
it('FE-PLUGINS-FRAME-059: the trek:ready handshake delivers the context too', () => {
const { iframe, posted } = mountFrame({ tripId: '9' });
fromFrame(iframe, { type: 'trek:ready' });
const ctx = posted.find((m) => m.type === 'trek:context')!;
expect(ctx).toBeTruthy();
expect(ctx.tripId).toBe('9');
expect(ctx.hostOrigin).toBe(window.location.origin);
});
it('FE-PLUGINS-FRAME-049: without a signed-in user the identity fields are null', () => {
host.user = null;
const { iframe, posted } = mountFrame();
fromFrame(iframe, { type: 'trek:context:request' });
const ctx = posted.find((m) => m.type === 'trek:context')!;
expect(ctx.userId).toBeNull();
expect(ctx.user).toBeNull();
});
it('FE-PLUGINS-FRAME-050: currency resolves user setting → trip currency → EUR', () => {
host.settings = { ...DEFAULT_SETTINGS, default_currency: '' };
host.trip = { currency: 'jpy' };
const withTrip = mountFrame();
fromFrame(withTrip.iframe, { type: 'trek:context:request' });
expect((withTrip.posted.find((m) => m.type === 'trek:context')!.formats as Record<string, unknown>).currency).toBe('JPY');
withTrip.unmount();
host.trip = null;
const bare = mountFrame();
fromFrame(bare.iframe, { type: 'trek:context:request' });
expect((bare.posted.find((m) => m.type === 'trek:context')!.formats as Record<string, unknown>).currency).toBe('EUR');
});
});
describe('live appearance sync', () => {
const contexts = (posted: Array<Record<string, unknown>>) => posted.filter((m) => m.type === 'trek:context');
it('FE-PLUGINS-FRAME-051: a theme change re-posts the context', async () => {
const { iframe, posted } = mountFrame();
act(() => { fireEvent.load(iframe); });
expect(contexts(posted)).toHaveLength(1);
act(() => { document.documentElement.classList.add('dark'); });
await waitFor(() => expect(contexts(posted).length).toBe(2));
expect(contexts(posted)[1].theme).toBe('dark');
});
it('FE-PLUGINS-FRAME-052: a mutation that does not change the look is not re-posted', async () => {
const html = document.documentElement;
const { iframe, posted } = mountFrame();
act(() => { fireEvent.load(iframe); });
act(() => { html.dataset.density = 'compact'; });
await waitFor(() => expect(contexts(posted).length).toBe(2));
// Re-writing the same value still fires a mutation record, but the
// appearance signature is unchanged — no second delivery.
act(() => { html.setAttribute('data-density', 'compact'); });
await new Promise((r) => setTimeout(r, 0));
expect(contexts(posted)).toHaveLength(2);
});
it('FE-PLUGINS-FRAME-053: a navigated frame is not re-styled', async () => {
const { iframe, posted } = mountFrame();
act(() => { fireEvent.load(iframe); });
act(() => { fireEvent.load(iframe); });
const before = contexts(posted).length;
act(() => { document.documentElement.classList.add('dark'); });
await new Promise((r) => setTimeout(r, 0));
expect(contexts(posted)).toHaveLength(before);
});
});
describe('websocket forwarding', () => {
it('FE-PLUGINS-FRAME-054: the plugin\'s own broadcasts pass, other plugins\' do not', () => {
const { iframe, posted } = mountFrame({ tripId: '42' });
act(() => { fireEvent.load(iframe); });
const emit = [...wsListeners][0];
emit({ type: 'plugin:other:ping', tripId: 42 });
emit({ type: 'plugin:demo:ping', tripId: 42 });
emit({ type: 'place_created', tripId: null });
const events = posted.filter((m) => m.type === 'trek:event');
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ event: 'plugin:demo:ping', tripId: '42' });
});
it('FE-PLUGINS-FRAME-055: nothing is forwarded before the first document load', () => {
const { posted } = mountFrame({ tripId: '42' });
const emit = [...wsListeners][0];
emit({ type: 'place_created', tripId: 42 });
expect(posted.filter((m) => m.type === 'trek:event')).toHaveLength(0);
});
});
});
@@ -991,7 +991,8 @@ describe('TodoListPanel — mobile layout', () => {
it('FE-COMP-TODO-078: the mobile new task sheet inherits the active list filter', async () => {
const items = [buildTodoItem({ id: 60, name: 'Task', category: 'Errands', checked: 0 })];
const { rerender } = render(<TodoListPanel tripId={1} items={items} addItemSignal={0} />);
clickFilter('Errands');
// On mobile the list filter is icon-only, so the label lives in the title.
fireEvent.click(screen.getByTitle('Errands'));
rerender(<TodoListPanel tripId={1} items={items} addItemSignal={1} />);
await screen.findByText('Create task');
@@ -724,7 +724,6 @@ describe('TripMembersModal', () => {
});
it('FE-COMP-MEMBERS-042: transferring ownership reloads the app', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const reload = vi.fn();
Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true, configurable: true });