Files
twitter-lite/src/features/decks/use-deck.test.ts
T

304 lines
9.5 KiB
TypeScript

import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, expect, it, vi } from 'vitest'
import { LEGACY_STORAGE_KEY } from './legacy-import'
import type { SavedDeck } from './persistence-model'
import { ACTIVE_DECK_KEY, useDeck } from './use-deck'
const server = vi.hoisted(() => ({
load: vi.fn(),
create: vi.fn(),
replace: vi.fn(),
remove: vi.fn(),
import: vi.fn(),
}))
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
vi.mock('./server-functions', () => ({
loadSavedDecks: server.load,
createSavedDeck: server.create,
replaceSavedDeck: server.replace,
deleteSavedDeck: server.remove,
importBrowserDecks: server.import,
}))
const saved: SavedDeck = {
id: 'saved',
title: 'Shared research',
columns: [],
revision: 1,
createdAt: 1,
updatedAt: 1,
}
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
server.load.mockResolvedValue({ ok: true, data: [] })
server.create.mockImplementation(async ({ data }) => ({
ok: true,
data: { ...data, revision: 1, createdAt: 1, updatedAt: 1 },
}))
})
it('creates and edits temporary views without writing shared decks', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
act(() => {
result.current.createTemporary('AI research')
})
const id = result.current.deck.id
await act(async () => {
await result.current.save({ id, title: 'Revised research', columns: [] })
})
expect(result.current.deck).toMatchObject({
title: 'Revised research',
persisted: false,
})
expect(server.create).not.toHaveBeenCalled()
expect(server.replace).not.toHaveBeenCalled()
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull()
await act(async () => {
await result.current.persist(id)
})
expect(result.current.deck).toMatchObject({
id,
persisted: true,
revision: 1,
})
expect(server.create).toHaveBeenCalledTimes(1)
await act(async () => {
await result.current.persist(id)
})
expect(server.create).toHaveBeenCalledTimes(1)
})
it('keeps a temporary view on failed save and retries with the same identity', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
act(() => {
result.current.createTemporary('Keep this')
})
const id = result.current.deck.id
server.create.mockResolvedValueOnce({
ok: false,
error: { message: 'Server disk unavailable' },
})
await act(async () => {
expect(await result.current.persist(id)).toBe(false)
})
expect(result.current.deck).toMatchObject({
id,
title: 'Keep this',
persisted: false,
})
expect(result.current.storageError).toBe('Server disk unavailable')
await act(async () => {
expect(await result.current.persist(id)).toBe(true)
})
expect(server.create.mock.calls.map((call) => call[0].data.id)).toEqual([
id,
id,
])
})
it('retains the accepted revision and surfaces conflicts instead of claiming failed edits were saved', async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] })
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
server.replace.mockResolvedValue({
ok: false,
error: { message: 'Revision conflict' },
})
await act(async () => {
expect(await result.current.save({ ...saved, title: 'My draft' })).toBe(
false,
)
})
expect(result.current.deck).toMatchObject({ title: saved.title, revision: 1 })
expect(result.current.storageError).toBe('Revision conflict')
expect(server.replace).toHaveBeenCalledWith({
data: {
deck: { id: saved.id, title: 'My draft', columns: [] },
expectedRevision: 1,
},
})
})
it('refreshes on focus but holds remote updates while a form is being edited', async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] })
const { result, rerender } = renderHook(({ editing }) => useDeck(editing), {
initialProps: { editing: false },
})
await waitFor(() => expect(result.current.ready).toBe(true))
rerender({ editing: true })
server.load.mockResolvedValue({
ok: true,
data: [{ ...saved, title: 'Remote edit', revision: 2 }],
})
await act(async () => {
window.dispatchEvent(new Event('focus'))
})
expect(server.load).toHaveBeenCalledTimes(1)
expect(result.current.deck.title).toBe(saved.title)
rerender({ editing: false })
await act(async () => {
window.dispatchEvent(new Event('focus'))
})
expect(result.current.deck).toMatchObject({
title: 'Remote edit',
revision: 2,
})
})
it('keeps the active deck local and copies saved content into a separate temporary view', async () => {
server.load.mockResolvedValue({
ok: true,
data: [saved, { ...saved, id: 'second', title: 'Second' }],
})
localStorage.setItem(ACTIVE_DECK_KEY, 'second')
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.deck.id).toBe('second'))
act(() => {
result.current.createTemporary('A copy', result.current.deck.columns)
})
const copy = result.current.deck
expect(copy.persisted).toBe(false)
expect(copy.id).not.toBe('second')
await act(async () => {
await result.current.save({ ...copy, title: 'Changed copy' })
})
expect(
result.current.workspace.decks.find((deck) => deck.id === 'second')?.title,
).toBe('Second')
expect(server.replace).not.toHaveBeenCalled()
})
it('does not import old browser data without an explicit action', async () => {
const legacy = {
version: 2,
activeDeckId: 'old',
decks: [{ id: 'old', title: 'Old deck', columns: [] }],
}
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify(legacy))
server.import.mockResolvedValue({ ok: true, data: [saved] })
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
expect(result.current.legacyAvailable).toBe(true)
expect(server.import).not.toHaveBeenCalled()
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe(JSON.stringify(legacy))
await act(async () => {
await result.current.importLegacy()
})
expect(server.import).toHaveBeenCalledWith({ data: legacy })
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull()
})
it('leaves unreadable legacy data untouched', async () => {
localStorage.setItem(LEGACY_STORAGE_KEY, '{broken')
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
expect(result.current.storageError).toContain('読み込めませんでした')
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe('{broken')
})
it('recognizes a committed save after a lost response without duplicating its temporary view', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
act(() => {
result.current.createTemporary('Lost response')
})
const view = result.current.deck
server.create.mockRejectedValueOnce(new Error('Network disconnected'))
await act(async () => {
await result.current.persist(view.id)
})
server.load.mockResolvedValue({
ok: true,
data: [
{
id: view.id,
title: view.title,
columns: view.columns,
revision: 1,
createdAt: 1,
updatedAt: 1,
},
],
})
await act(async () => {
await result.current.refresh()
})
expect(
result.current.workspace.decks.filter((deck) => deck.id === view.id),
).toHaveLength(1)
expect(result.current.deck.persisted).toBe(true)
})
it('preserves a divergent temporary edit when a lost-response save appears remotely', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
act(() => {
result.current.createTemporary('Before request')
})
const view = result.current.deck
server.create.mockRejectedValueOnce(new Error('Network disconnected'))
await act(async () => {
await result.current.persist(view.id)
})
await act(async () => {
await result.current.save({ ...view, title: 'Edited after failure' })
})
server.load.mockResolvedValue({
ok: true,
data: [
{
id: view.id,
title: view.title,
columns: view.columns,
revision: 1,
createdAt: 1,
updatedAt: 1,
},
],
})
await act(async () => {
await result.current.refresh()
})
expect(result.current.deck).toMatchObject({
title: 'Edited after failure',
persisted: false,
})
expect(result.current.deck.id).not.toBe(view.id)
expect(
result.current.workspace.decks.find((deck) => deck.id === view.id),
).toMatchObject({ title: 'Before request', persisted: true })
})
it('ignores an older refresh response after a newer request has completed', async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] })
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
let resolveOlder: (value: unknown) => void = () => {}
server.load.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveOlder = resolve
}),
)
act(() => {
void result.current.refresh()
})
server.load.mockResolvedValue({
ok: true,
data: [{ ...saved, title: 'Newest', revision: 3 }],
})
await act(async () => {
await result.current.refresh()
})
expect(result.current.deck.revision).toBe(3)
await act(async () => {
resolveOlder({
ok: true,
data: [{ ...saved, title: 'Older', revision: 2 }],
})
})
expect(result.current.deck).toMatchObject({ title: 'Newest', revision: 3 })
})