Files
TREK/client/tests/unit/studio/SaveIndicator.test.tsx
T
Maurice a7b53988ff feat(studio): rethink the travel tab with many new looks, a saved tile and a newer-book overlay
The travel tab is four tabs now, figures, maps, countries and marks, and
offers only the newer looks, each dressed in the book's theme and linked to
it. The looks books already hold still draw on their pages exactly as
before; they are just no longer offered.

- Figures: a summary as a poster, a ticket, tiles, a road sign, an
  odometer, a handwritten note, a pencil sketch or a band, and each figure
  on its own as a medal, on odometer drums or ringed by hand.
- Maps: the route with its photographs, its stops numbered, its two ends
  marked or on a dark ground, over satellite or relief imagery, a map per
  trip, and a map of each country with its stops alone, cut to its shape.
- Countries: each country as a passport stamp, a visa, an airmail
  postcard, its flag in cloth, a map pin, a luggage label or a sketch.
- Marks: the date, the day, the distance, the coordinates and the flag as
  stamps, tags, pills, ribbons, die-cut stickers, embroidered patches and
  sketches.

The schema gains a summary's and a country's look, seven mark styles and
the numbered and ends stops of a map, all additive, as revision 11.

Also in Studio: the save state is a tile beside who is here, a book a newer
Studio saved is said by an overlay that holds the whole Studio until it
reloads, and the page rail draws the first and the last page beside the
blank inside of their cover. The notifications page and the bell's panel
take the dialogs' look: a head with a tile, cards with an unread dot, and
tools that come forward on hover.
2026-10-24 14:45:04 +02:00

124 lines
4.3 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest'
import type { BookRecord } from '@trek/shared'
import { fireEvent, render, screen } from '../../helpers/render'
import { NewerBookOverlay, SaveIndicator } from '../../../src/components/Studio/SaveIndicator'
/**
* The save status (#1973).
*
* Autosave without a sign of it is a promise the user has to take on faith, and
* the moment they stop doing that is the moment they close the tab to check. So
* the interesting cases here are the two that are not "fine": an error the user
* can retry, and a conflict that has to offer a real choice rather than
* announcing that one exists.
*/
const t = (k: string) => k
const current = (over: Partial<BookRecord> = {}): BookRecord => ({
id: 3, journeyId: 9, title: 'Theirs', version: 6,
updatedAt: '2026-08-19 10:00:00', updatedBy: 2,
document: { version: 1, title: 'Theirs', page: {}, spreads: [] },
...over,
} as BookRecord)
function draw(state: Parameters<typeof SaveIndicator>[0]['state'], handlers = {}) {
const props = {
state, t,
onAcceptTheirs: vi.fn(), onKeepMine: vi.fn(), onRetry: vi.fn(),
...handlers,
}
render(<SaveIndicator {...props} />)
return props
}
describe('the quiet states', () => {
/*
* Idle shows nothing at all. A status that is always there is a status people
* learn to stop reading, which is exactly the wrong habit for the one time it
* says something else.
*/
it('renders nothing while idle', () => {
const { container } = render(
<SaveIndicator
state={{ status: 'idle' }} t={t}
onAcceptTheirs={vi.fn()} onKeepMine={vi.fn()} onRetry={vi.fn()}
/>,
)
expect(container.textContent).toBe('')
})
it('says it is saving, and then that it saved', () => {
const { unmount } = render(
<SaveIndicator
state={{ status: 'saving' }} t={t}
onAcceptTheirs={vi.fn()} onKeepMine={vi.fn()} onRetry={vi.fn()}
/>,
)
expect(screen.getByText('journey.studio.saving')).toBeTruthy()
unmount()
draw({ status: 'saved', at: 0 })
expect(screen.getByText('journey.studio.saved')).toBeTruthy()
})
})
describe('an error', () => {
it('is a button, because the way out is to try again', () => {
const props = draw({ status: 'error' })
fireEvent.click(screen.getByText('journey.studio.saveFailed'))
expect(props.onRetry).toHaveBeenCalled()
})
})
describe('a conflict', () => {
/*
* There is no merge here, and pretending otherwise would be worse than saying
* so: one of these two documents is going to be the book. Both ways out are
* on screen, stated plainly.
*/
it('offers both ways out, and hands back the record it was given', () => {
const record = current()
const props = draw({ status: 'conflict', current: record })
expect(screen.getByText('journey.studio.saveConflict')).toBeTruthy()
fireEvent.click(screen.getByText('journey.studio.saveTakeTheirs'))
expect(props.onAcceptTheirs).toHaveBeenCalledWith(record)
fireEvent.click(screen.getByText('journey.studio.saveKeepMine'))
expect(props.onKeepMine).toHaveBeenCalledWith(record)
})
/*
* Announced, not just drawn. Losing an edit to someone else's save is the one
* thing here a screen-reader user must not have to notice by scanning.
*/
it('is announced rather than only drawn', () => {
draw({ status: 'conflict', current: current() })
expect(screen.getByRole('alert')).toBeTruthy()
})
})
describe('a book a newer Studio saved', () => {
/*
* Nothing done in this tab is kept until it reloads, so the bar says nothing
* of its own and the overlay over the whole Studio offers the one way out.
*/
it('leaves the bar to the overlay, which says why, takes the focus and reloads on request', () => {
const { container } = render(
<SaveIndicator state={{ status: 'newer' }} t={t} onAcceptTheirs={vi.fn()} onKeepMine={vi.fn()} onRetry={vi.fn()} />,
)
expect(container.textContent).toBe('')
const onReload = vi.fn()
render(<NewerBookOverlay t={t} onReload={onReload} />)
expect(screen.getByRole('alertdialog')).toBeTruthy()
expect(screen.getByText('journey.studio.saveNewer')).toBeTruthy()
const reload = screen.getByRole('button', { name: 'journey.studio.saveReload' })
expect(document.activeElement).toBe(reload)
fireEvent.click(reload)
expect(onReload).toHaveBeenCalledTimes(1)
})
})