Files
TREK/client/tests/unit/studio/SaveIndicator.test.tsx
T
Maurice 30382dc366 feat(studio): a book saved by a newer Studio opens read-only instead of losing what it cannot read
- A book carries schemaRev. The reader that meets a newer one opens the book
  read-only with a reload banner and saves nothing, where it used to drop the
  elements it could not read on the next save.
- The server refuses a save, a restore or a tool edit that would write an older
  revision over a newer book or one newer than itself (409 book_schema_newer),
  on REST and through every book tool alike.
- normalizeBookDocument moves to book-normalize.ts.
- Print waits for photos eagerly and up to 15 s for an export of more than 40
  photos, and skips images without a source.
2026-10-23 14:15:01 +02:00

118 lines
3.9 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest'
import type { BookRecord } from '@trek/shared'
import { fireEvent, render, screen } from '../../helpers/render'
import { 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 state is loud
* like a conflict and offers the one way out there is.
*/
it('is announced, says why, and reloads on request', () => {
const onReload = vi.fn()
draw({ status: 'newer' }, { onReload })
expect(screen.getByRole('alert')).toBeTruthy()
expect(screen.getByText('journey.studio.saveNewer')).toBeTruthy()
fireEvent.click(screen.getByText('journey.studio.saveReload'))
expect(onReload).toHaveBeenCalledTimes(1)
})
})