mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
cb7014b378
Costs gets a second view next to the ledger: the expenses as a table grouped by category, with name, date, total, persons and days editable in place and the per person and per day figures worked out beside them. A total with a payer or in a foreign currency stays locked and opens the expense instead, so balances and frozen rates are never changed behind the dialog. In the table view the side column shows a summary by category, day, payer and status. The switch sits in the bar and the choice is remembered; the ledger stays the default. On the Plan tab a place's reservation shows as one Reservation pill whose colour carries the status and which opens the booking, and the drop line of a drag that lost its source row now clears on the next mouse move. The booking detail formats its times in the user's clock, and a hotel without a check-out runs to the end of the last day in the timeline. The wiki pages of the trip are rewritten for the new layout, with new screenshots from the screenshot run (new Plan tab, bookings, transports, dialogs, costs table). The help center guides of the Plan tab are rewritten and translated into every locale, and every new string is translated.
168 lines
8.0 KiB
TypeScript
168 lines
8.0 KiB
TypeScript
// FE-BUDGET-SCAN-001 to FE-BUDGET-SCAN-011
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { http, HttpResponse } from 'msw'
|
|
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
|
|
import { server } from '../../../tests/helpers/msw/server'
|
|
import { saveImportFiles } from '../../db/offlineDb'
|
|
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
|
|
import { useReceiptScan } from './useReceiptScan'
|
|
import { ReceiptScanModal } from './ReceiptScanModal'
|
|
import MReceiptScanButton from '../../mobile/screens/trip/tabs/MReceiptScanButton'
|
|
|
|
vi.mock('../../db/offlineDb', () => ({ saveImportFiles: vi.fn(async () => {}) }))
|
|
|
|
function Harness({ canEdit = true }: { canEdit?: boolean }) {
|
|
const scan = useReceiptScan(7, canEdit)
|
|
return (
|
|
<>
|
|
{scan.offered && <button type="button" onClick={scan.open}>Scan receipt</button>}
|
|
<ReceiptScanModal scan={scan} />
|
|
</>
|
|
)
|
|
}
|
|
|
|
const input = () => screen.getByTestId('receipt-scan-input') as HTMLInputElement
|
|
const photo = (name = 'bill.jpg', size = 4) => {
|
|
const f = new File(['x'.repeat(Math.min(size, 16))], name, { type: 'image/jpeg' })
|
|
if (size > 16) Object.defineProperty(f, 'size', { value: size })
|
|
return f
|
|
}
|
|
async function openDialog() {
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Scan receipt' }))
|
|
return screen.getByRole('dialog', { name: 'Scan a receipt' })
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(saveImportFiles).mockClear()
|
|
useBackgroundTasksStore.setState({ tasks: [] })
|
|
server.use(
|
|
http.get('/api/llm/capabilities', () => HttpResponse.json({ images: true })),
|
|
http.post('/api/trips/7/budget/receipt-scan', () => HttpResponse.json({ jobId: 'job-5' })),
|
|
)
|
|
})
|
|
|
|
describe('Scan receipt', () => {
|
|
it('FE-BUDGET-SCAN-001: offers the scan when the model reads images, and opens a dialog saying what it takes', async () => {
|
|
render(<Harness />)
|
|
await openDialog()
|
|
expect(screen.getByText(/A photo of one receipt \(JPG, PNG or WEBP, up to 10 MB\)/)).toBeInTheDocument()
|
|
expect(input().accept).toContain('image/jpeg')
|
|
expect(screen.getByRole('button', { name: 'Scan' })).toBeDisabled()
|
|
})
|
|
|
|
it('FE-BUDGET-SCAN-002: offers nothing, and asks nothing, without budget_edit', async () => {
|
|
let asked = false
|
|
server.use(http.get('/api/llm/capabilities', () => { asked = true; return HttpResponse.json({ images: true }) }))
|
|
render(<Harness canEdit={false} />)
|
|
await new Promise(r => setTimeout(r, 20))
|
|
expect(asked).toBe(false)
|
|
expect(screen.queryByRole('button', { name: 'Scan receipt' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-BUDGET-SCAN-003: offers nothing when the model reads no images', async () => {
|
|
server.use(http.get('/api/llm/capabilities', () => HttpResponse.json({ images: false })))
|
|
render(<Harness />)
|
|
await new Promise(r => setTimeout(r, 20))
|
|
expect(screen.queryByRole('button', { name: 'Scan receipt' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-BUDGET-SCAN-004: a picked photo is named, then Scan starts a costs job the widget follows and closes the dialog', async () => {
|
|
render(<Harness />)
|
|
await openDialog()
|
|
const picked = photo()
|
|
fireEvent.change(input(), { target: { files: [picked] } })
|
|
expect(screen.getByText('bill.jpg')).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Scan' }))
|
|
await waitFor(() => expect(useBackgroundTasksStore.getState().tasks[0]).toMatchObject({ id: 'job-5', tripId: '7', label: 'bill.jpg', kind: 'costs', status: 'running' }))
|
|
expect(saveImportFiles).toHaveBeenCalledWith('job-5', [picked])
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
|
})
|
|
|
|
it('FE-BUDGET-SCAN-005: a dropped photo is taken like a picked one', async () => {
|
|
render(<Harness />)
|
|
await openDialog()
|
|
const zone = screen.getByText('Drop the receipt photo here, or click to pick or take one').closest('button')!
|
|
fireEvent.dragOver(zone)
|
|
expect(screen.getByText('Drop the photo to scan it')).toBeInTheDocument()
|
|
fireEvent.drop(zone, { dataTransfer: { files: [photo('drop.png')] } })
|
|
expect(screen.getByText('drop.png')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'Scan' })).toBeEnabled()
|
|
})
|
|
|
|
it('FE-BUDGET-SCAN-006: refuses what is not a photo, and a photo over 10 MB, before anything is sent', async () => {
|
|
let sent = false
|
|
server.use(http.post('/api/trips/7/budget/receipt-scan', () => { sent = true; return HttpResponse.json({ jobId: 'x' }) }))
|
|
render(<Harness />)
|
|
await openDialog()
|
|
fireEvent.change(input(), { target: { files: [new File(['%PDF'], 'bill.pdf', { type: 'application/pdf' })] } })
|
|
expect(screen.getByRole('alert')).toHaveTextContent('Only a photo can be scanned: JPG, PNG or WEBP.')
|
|
fireEvent.change(input(), { target: { files: [photo('big.jpg', 11 * 1024 * 1024)] } })
|
|
expect(screen.getByRole('alert')).toHaveTextContent('File "big.jpg" exceeds 10 MB limit.')
|
|
expect(screen.getByRole('button', { name: 'Scan' })).toBeDisabled()
|
|
expect(sent).toBe(false)
|
|
})
|
|
|
|
it('FE-BUDGET-SCAN-007: says why when the server refuses, and keeps the dialog open', async () => {
|
|
server.use(http.post('/api/trips/7/budget/receipt-scan', () => HttpResponse.json({ error: 'The configured AI model does not read photos' }, { status: 400 })))
|
|
render(<Harness />)
|
|
await openDialog()
|
|
fireEvent.change(input(), { target: { files: [photo()] } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'Scan' }))
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('The configured AI model does not read photos')
|
|
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
|
expect(useBackgroundTasksStore.getState().tasks).toEqual([])
|
|
})
|
|
|
|
it('FE-BUDGET-SCAN-008: Cancel, the close button and the backdrop close it; reopening starts empty', async () => {
|
|
render(<Harness />)
|
|
const dialog = await openDialog()
|
|
fireEvent.change(input(), { target: { files: [photo()] } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
|
|
await openDialog()
|
|
expect(screen.queryByText('bill.jpg')).not.toBeInTheDocument()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
|
|
await openDialog()
|
|
// Only a press that starts and ends on the backdrop closes it.
|
|
const backdrop = screen.getByRole('dialog').parentElement!
|
|
fireEvent.mouseDown(backdrop)
|
|
fireEvent.click(backdrop)
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
expect(dialog).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('MReceiptScanButton', () => {
|
|
it('FE-BUDGET-SCAN-009: the phone header button opens the same dialog', async () => {
|
|
render(<MReceiptScanButton tripId={7} canEdit />)
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Scan receipt' }))
|
|
expect(screen.getByRole('dialog', { name: 'Scan a receipt' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-BUDGET-SCAN-010: draws nothing when the scan is not offered', async () => {
|
|
const { container } = render(<MReceiptScanButton tripId={7} canEdit={false} />)
|
|
await new Promise(r => setTimeout(r, 20))
|
|
expect(container).toBeEmptyDOMElement()
|
|
})
|
|
|
|
it('FE-BUDGET-SCAN-011: the drop zone lights up while a photo is dragged over it, and a click on it opens the picker', async () => {
|
|
render(<Harness />)
|
|
await openDialog()
|
|
const zone = screen.getByText('Drop the receipt photo here, or click to pick or take one').closest('button')!
|
|
fireEvent.dragEnter(zone)
|
|
expect(screen.getByText('Drop the photo to scan it')).toBeInTheDocument()
|
|
// Leaving for a child of the zone keeps it lit, leaving the zone itself does not.
|
|
fireEvent.dragLeave(zone.firstElementChild!)
|
|
expect(screen.getByText('Drop the photo to scan it')).toBeInTheDocument()
|
|
fireEvent.dragLeave(zone)
|
|
expect(screen.getByText('Drop the receipt photo here, or click to pick or take one')).toBeInTheDocument()
|
|
const pick = vi.spyOn(input(), 'click')
|
|
fireEvent.click(zone)
|
|
expect(pick).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|