mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +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:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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 });
|
||||
|
||||
@@ -28,10 +28,10 @@ export default defineConfig({
|
||||
// down to ~48% unnoticed. Set a few points below the current run so an
|
||||
// ordinary PR doesn't trip them; raise them when the number rises.
|
||||
thresholds: {
|
||||
statements: 90,
|
||||
branches: 82,
|
||||
functions: 89,
|
||||
lines: 92,
|
||||
statements: 94,
|
||||
branches: 87,
|
||||
functions: 94,
|
||||
lines: 96,
|
||||
},
|
||||
},
|
||||
css: false,
|
||||
|
||||
Reference in New Issue
Block a user