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>
312 lines
9.8 KiB
JavaScript
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'])
|
|
})
|
|
})
|