thats-me/frontend/test/vitest/events-draft.spec.js
Kevin Adametz 83c6c5813d Frontend: Offline-Warteschlange, PWA, Event-Panel und Markenauftritt
Offline
- Alle Schreibzugriffe von API-Nutzern laufen über eine Warteschlange in
  IndexedDB (services/syncQueue.js): FIFO, zusammengefasste Updates,
  Wiederholung bei Netz/Sichtbarkeit/Backoff, 4xx wird als Fehler gemeldet.
- Medien-Uploads liegen mit Bytes in der Queue, erscheinen sofort mit
  Marker und werden nach dem Upload durch das Server-Objekt ersetzt.
- PWA-Modus mit Service Worker für die App-Hülle; API nie gecacht.
  npm run build erzeugt dist/pwa.
- Speicherzustand im Panel: ausstehend / Fehler mit Wiederholen-Knopf,
  Verbindungsanzeige im Header.

Event
- Event entsteht erst mit dem ersten Eintrag; jede Löschung fragt nach.
- Key Image als 4:3-Rahmen, Galerie mit Video, mehrere Sprachnotizen mit
  Notiz, Live-Pegel beim Aufnehmen, WAV-Umwandlung für lokale Aufnahmen.
- Neue Felder: Zeitraum, Kategorie, Personen, Meilenstein; Stimmung als
  kompakte Zeile mit ausklappbarer Punktfarbe.

Oberfläche
- Untere Leiste: Darstellung und Zoom links, Jahres-Pille mittig, Plus rechts.
- Logos und Goldpalette, Login neu, zehn Wellen-Presets mit Vorschau,
  Hintergrundbilder als Kacheln, Kontomenü und Einstellungen bereinigt,
  Escape schließt Ebenen, größere Touch-Ziele.
- Alten Sync-Service und ungenutzte Legacy-Seiten entfernt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 07:00:35 +00:00

312 lines
9.8 KiB
JavaScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { createApiRouter, drainQueue, flush, page, remoteEvent } from './helpers'
let api = createApiRouter()
vi.mock('src/services/apiClient', () => ({
API_BASE: 'http://api.test',
apiJson: (...args) => api.apiJson(...args),
apiFetch: vi.fn(),
getToken: vi.fn(async () => 'token'),
setToken: vi.fn(async () => {}),
clearToken: vi.fn(async () => {}),
onUnauthorized: vi.fn(() => () => {}),
revokeToken: vi.fn(async () => {}),
loginRemote: vi.fn(),
ApiError: class ApiError extends Error {}
}))
const EVENTS_LIST = /^\/events\?/
const EVENTS_CREATE = (url, options) => url === '/events' && options?.method === 'POST'
const EVENT_UPDATE = (url, options) => /^\/events\/[^/]+$/.test(url) && options?.method === 'PUT'
// Past the 300 ms autosave debounce.
async function settle() {
await flush(2)
await new Promise(resolve => setTimeout(resolve, 400))
await flush(2)
}
describe('events store — a new event is stored with its first entry', () => {
let eventsStore
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
vi.resetModules()
const { db } = await import('src/db')
await db.events.clear()
const { useEventsStore } = await import('stores/events')
const { useAuthStore } = await import('stores/auth')
api.once(EVENTS_LIST, page([remoteEvent({ id: 'e1', title: 'vorhanden' })]))
api.always(EVENTS_CREATE, (url, options) => Promise.resolve({
data: remoteEvent({ ...(options.body ?? {}) })
}))
api.always(EVENT_UPDATE, (url, options) => Promise.resolve({
data: remoteEvent({ id: url.split('/').pop(), ...(options.body ?? {}) })
}))
const authStore = useAuthStore()
eventsStore = useEventsStore()
authStore.currentUserId = 'remote-user-1'
await flush()
})
// A queue still sending when the test ends would hit the next test's API
// mock; let it finish first.
afterEach(async () => {
eventsStore.closePanel()
await drainQueue(eventsStore)
})
it('opens the panel as a draft without creating an event', async () => {
eventsStore.openPanel()
await settle()
expect(eventsStore.panelOpen).toBe(true)
expect(eventsStore.editingEventId).toBeNull()
expect(eventsStore.events).toHaveLength(1)
expect(api.callsTo(EVENTS_CREATE)).toHaveLength(0)
})
it('leaves nothing behind when the empty draft is closed', async () => {
eventsStore.openPanel()
await flush(2)
eventsStore.closePanel()
await settle()
expect(eventsStore.events).toHaveLength(1)
expect(api.callsTo(EVENTS_CREATE)).toHaveLength(0)
const { db } = await import('src/db')
expect(await db.events.count()).toBe(1)
})
it('does not treat date or emotion alone as an entry', async () => {
eventsStore.openPanel()
await flush(2)
eventsStore.ghostDate = '2024-06-01'
eventsStore.ghostEmotion = 0.8
await settle()
expect(eventsStore.editingEventId).toBeNull()
expect(eventsStore.events).toHaveLength(1)
expect(api.callsTo(EVENTS_CREATE)).toHaveLength(0)
})
it('creates exactly one event with the first title and updates it afterwards', async () => {
eventsStore.openPanel()
await flush(2)
eventsStore.ghostTitle = 'Erster Tag'
await flush(2)
expect(eventsStore.editingEventId).not.toBeNull()
const createdId = eventsStore.editingEventId
const created = eventsStore.events.find(event => event.id === createdId)
expect(created).toBeDefined()
expect(created.title).toBe('Erster Tag')
expect(eventsStore.selectedEventId).toBe(createdId)
eventsStore.ghostTitle = 'Erster Tag am Meer'
eventsStore.ghostNote = 'Sonne'
await settle()
const creates = api.callsTo(EVENTS_CREATE)
expect(creates).toHaveLength(1)
expect(creates[0].options.body.title).toBe('Erster Tag')
const updates = api.callsTo(EVENT_UPDATE).filter(call => call.url === `/events/${createdId}`)
expect(updates.length).toBeGreaterThan(0)
expect(updates.at(-1).options.body.title).toBe('Erster Tag am Meer')
expect(updates.at(-1).options.body.note).toBe('Sonne')
expect(eventsStore.events).toHaveLength(2)
})
it('keeps the created event when the panel is closed', async () => {
eventsStore.openPanel()
await flush(2)
eventsStore.ghostNote = 'nur eine Notiz'
await flush(2)
const createdId = eventsStore.editingEventId
eventsStore.closePanel()
await settle()
expect(eventsStore.events.find(event => event.id === createdId)?.note).toBe('nur eine Notiz')
const { db } = await import('src/db')
expect(await db.events.get(createdId)).toBeDefined()
})
it('starts each new draft clean, even after editing another event', async () => {
eventsStore.openPanel('e1')
await flush(2)
expect(eventsStore.ghostTitle).toBe('vorhanden')
eventsStore.openPanel()
await flush(2)
expect(eventsStore.ghostTitle).toBe('')
expect(eventsStore.ghostNote).toBe('')
expect(eventsStore.ghostMedia).toEqual([])
expect(eventsStore.ghostHasContent).toBe(false)
expect(eventsStore.editingEventId).toBeNull()
})
it('does not resurrect the existing event as a "Neues Event" copy', async () => {
eventsStore.openPanel()
await flush(2)
eventsStore.closePanel()
await settle()
expect(eventsStore.events.map(event => event.title)).toEqual(['vorhanden'])
})
})
describe('events store — drafts for the local demo user', () => {
let eventsStore
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
vi.resetModules()
const { db } = await import('src/db')
await db.events.clear()
const { useEventsStore } = await import('stores/events')
const { useAuthStore } = await import('stores/auth')
const authStore = useAuthStore()
eventsStore = useEventsStore()
authStore.currentUserId = 'local-demo'
await flush()
})
// A queue still sending when the test ends would hit the next test's API
// mock; let it finish first.
afterEach(async () => {
eventsStore.closePanel()
await drainQueue(eventsStore)
})
it('stores the draft locally with its first entry and never calls the API', async () => {
eventsStore.openPanel()
await flush(2)
expect(eventsStore.events).toHaveLength(0)
eventsStore.ghostLocation = 'Berlin'
await settle()
expect(eventsStore.events).toHaveLength(1)
expect(eventsStore.events[0].location).toBe('Berlin')
expect(eventsStore.saveState).toBe('saved')
expect(api.callsTo(EVENTS_CREATE)).toHaveLength(0)
const { db } = await import('src/db')
expect(await db.events.count()).toBe(1)
})
})
describe('events store — category, people, span and milestone', () => {
let eventsStore
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
vi.resetModules()
const { db } = await import('src/db')
await db.events.clear()
await db.syncQueue.clear()
const { useEventsStore } = await import('stores/events')
const { useAuthStore } = await import('stores/auth')
api.once(EVENTS_LIST, page([remoteEvent({
id: 'e1', title: 'Umzug', endDate: '2019-04-14', category: 'home', people: ['Lena'], milestone: true
})]))
api.always(EVENTS_CREATE, (url, options) => Promise.resolve({ data: remoteEvent({ ...(options.body ?? {}) }) }))
api.always(EVENT_UPDATE, (url, options) => Promise.resolve({
data: remoteEvent({ id: url.split('/').pop(), ...(options.body ?? {}) })
}))
const authStore = useAuthStore()
eventsStore = useEventsStore()
authStore.currentUserId = 'remote-user-1'
await flush()
})
afterEach(async () => {
eventsStore.closePanel()
await drainQueue(eventsStore)
})
it('loads the fields from the API and puts them into the draft', async () => {
const event = eventsStore.events.find(entry => entry.id === 'e1')
expect(event.endDate).toBe('2019-04-14')
expect(event.category).toBe('home')
expect(event.people).toEqual(['Lena'])
expect(event.milestone).toBe(true)
eventsStore.openPanel('e1')
await flush(2)
expect(eventsStore.ghostEndDate).toBe('2019-04-14')
expect(eventsStore.ghostCategory).toBe('home')
expect(eventsStore.ghostPeople).toEqual(['Lena'])
expect(eventsStore.ghostMilestone).toBe(true)
})
it('a category or a person is an entry that stores the draft', async () => {
eventsStore.openPanel()
await flush(2)
eventsStore.ghostMilestone = true
await settle()
// A flag alone is no content.
expect(eventsStore.editingEventId).toBeNull()
eventsStore.ghostCategory = 'travel'
await settle()
expect(eventsStore.editingEventId).not.toBeNull()
const creates = api.callsTo(EVENTS_CREATE)
expect(creates).toHaveLength(1)
expect(creates[0].options.body.category).toBe('travel')
expect(creates[0].options.body.milestone).toBe(true)
expect(creates[0].options.body.people).toEqual([])
expect(creates[0].options.body.endDate).toBeNull()
})
it('sends people, span and milestone on update and keeps them on the event', async () => {
eventsStore.openPanel('e1')
await flush(2)
eventsStore.ghostPeople = ['Lena', 'Papa']
eventsStore.ghostEndDate = '2019-04-20'
eventsStore.ghostMilestone = false
await settle()
await drainQueue(eventsStore)
const puts = api.callsTo(EVENT_UPDATE)
const last = puts[puts.length - 1].options.body
expect(last.people).toEqual(['Lena', 'Papa'])
expect(last.endDate).toBe('2019-04-20')
expect(last.milestone).toBe(false)
const event = eventsStore.events.find(entry => entry.id === 'e1')
expect(event.people).toEqual(['Lena', 'Papa'])
expect(event.endDate).toBe('2019-04-20')
expect(event.milestone).toBe(false)
const { db } = await import('src/db')
expect((await db.events.get('e1')).people).toEqual(['Lena', 'Papa'])
})
})