mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
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:
@@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user