thats-me/frontend/test/vitest/components-feedback.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

270 lines
7.9 KiB
JavaScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h } from 'vue'
import { mount } from '@vue/test-utils'
import { routerKey } from 'vue-router'
import { createApiRouter, 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 {}
}))
// Static imports on purpose: Quasar installs `$q` per mounted app, and a
// module reset between tests would leave the stores and the components with
// different copies of the same singletons. A fresh Pinia per test isolates
// the store state.
import { db } from 'src/db'
import { useEventsStore } from 'stores/events'
import { useSettingsStore } from 'stores/settings'
import { useAuthStore } from 'stores/auth'
import { useStoreFeedback } from 'composables/useStoreFeedback'
import EventPanel from 'components/EventPanel.vue'
const EVENTS_LIST = /^\/events\?/
function fakeRouter(path = '/') {
return {
currentRoute: { value: { path } },
replace: vi.fn()
}
}
/**
* A component that does nothing but run the composable. It stands in for the
* layout, which owns a WebGL scene and cannot be mounted in a DOM-only
* environment.
*/
const FeedbackHost = defineComponent({
setup() {
useStoreFeedback()
return () => h('div')
}
})
describe('useStoreFeedback — store failures become notifications', () => {
let notify
let eventsStore
let settingsStore
let authStore
let router
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
await db.events.clear()
eventsStore = useEventsStore()
settingsStore = useSettingsStore()
authStore = useAuthStore()
router = fakeRouter('/')
const wrapper = mount(FeedbackHost, {
global: { provide: { [routerKey]: router } }
})
// `$q.notify` is looked up on each call, so spying on the mounted app's
// `$q` intercepts what the composable sends.
notify = vi.spyOn(wrapper.vm.$q, 'notify').mockImplementation(() => () => {})
})
it('shows a write failure as a negative notification', async () => {
eventsStore.lastError = 'Das Event konnte nicht gespeichert werden.'
await flush(1)
expect(notify).toHaveBeenCalledTimes(1)
expect(notify.mock.calls[0][0]).toMatchObject({
type: 'negative',
message: 'Das Event konnte nicht gespeichert werden.'
})
})
it('shows a load failure as a warning', async () => {
eventsStore.loadError = 'Die Events konnten nicht geladen werden.'
await flush(1)
expect(notify.mock.calls[0][0]).toMatchObject({ type: 'warning' })
})
it('shows a settings failure as a warning', async () => {
settingsStore.lastError = 'Die Einstellungen konnten nicht gespeichert werden.'
await flush(1)
expect(notify.mock.calls[0][0]).toMatchObject({
type: 'warning',
message: 'Die Einstellungen konnten nicht gespeichert werden.'
})
})
it('consumes the message so the identical error notifies again', async () => {
eventsStore.lastError = 'Gleiche Meldung'
await flush(1)
expect(eventsStore.lastError).toBeNull()
eventsStore.lastError = 'Gleiche Meldung'
await flush(1)
expect(notify).toHaveBeenCalledTimes(2)
})
it('does not notify when the error is cleared', async () => {
eventsStore.lastError = null
await flush(1)
expect(notify).not.toHaveBeenCalled()
})
it('redirects to /login once the session has ended', async () => {
authStore.currentUserId = 'remote-user-1'
await flush(1)
expect(router.replace).not.toHaveBeenCalled()
authStore.currentUserId = null
await flush(1)
expect(router.replace).toHaveBeenCalledWith('/login')
})
it('does not redirect when already on /login', async () => {
router.currentRoute.value.path = '/login'
authStore.currentUserId = 'remote-user-1'
await flush(1)
authStore.currentUserId = null
await flush(1)
expect(router.replace).not.toHaveBeenCalled()
})
})
describe('EventPanel — the save state is visible', () => {
let eventsStore
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
await db.events.clear()
api.once(EVENTS_LIST, page([remoteEvent({ id: 'e1', title: 'Event A' })]))
api.always(/^\/events\/e1$/, (url, options) => Promise.resolve({
data: remoteEvent({ id: 'e1', ...(options.body ?? {}) })
}))
eventsStore = useEventsStore()
useAuthStore().currentUserId = 'remote-user-1'
await flush()
})
function mountPanel() {
return mount(EventPanel, {
global: {
stubs: {
// Not under test, and they pull in portals and carousels.
QDialog: true,
QCarousel: true,
QCarouselSlide: true,
QPopupProxy: true,
QDate: true
}
}
})
}
it('renders nothing about saving on a freshly opened panel', async () => {
eventsStore.openPanel('e1')
const wrapper = mountPanel()
await flush(1)
expect(wrapper.find('.event-panel__save-state').exists()).toBe(false)
})
it('shows "Gespeichert" after a successful autosave', async () => {
eventsStore.openPanel('e1')
const wrapper = mountPanel()
await flush(1)
eventsStore.ghostTitle = 'geändert'
await flush(1)
eventsStore.saveGhostNow()
await flush()
const state = wrapper.find('.event-panel__save-state')
expect(state.exists()).toBe(true)
expect(state.text()).toBe('Gespeichert')
expect(state.classes()).toContain('event-panel__save-state--saved')
})
it('shows "Nicht gespeichert" when the server rejects the write', async () => {
api = createApiRouter()
api.always(/^\/events\/e1$/, () => Promise.reject(
Object.assign(new Error('unprocessable'), { status: 422 })
))
eventsStore.openPanel('e1')
const wrapper = mountPanel()
await flush(1)
eventsStore.ghostTitle = 'abgelehnt'
await flush(1)
eventsStore.saveGhostNow()
await flush()
const state = wrapper.find('.event-panel__save-state')
expect(state.find('span').text()).toBe('Nicht gespeichert')
expect(state.classes()).toContain('event-panel__save-state--error')
expect(state.attributes('role')).toBe('status')
// A failed write offers a way out.
expect(state.find('.event-panel__save-retry').text()).toBe('Erneut versuchen')
})
it('shows "Wird gespeichert …" while the request is in flight', async () => {
let resolveWrite
api = createApiRouter()
api.always(/^\/events\/e1$/, () => new Promise((resolve) => { resolveWrite = resolve }))
eventsStore.openPanel('e1')
const wrapper = mountPanel()
await flush(1)
eventsStore.ghostTitle = 'unterwegs'
await flush(1)
eventsStore.saveGhostNow()
// The write leaves through the queue, which takes a few IndexedDB turns.
await flush()
expect(wrapper.find('.event-panel__save-state').text()).toBe('Wird gespeichert …')
expect(resolveWrite).toBeTypeOf('function')
resolveWrite({ data: remoteEvent({ id: 'e1', title: 'unterwegs' }) })
await flush()
expect(wrapper.find('.event-panel__save-state').text()).toBe('Gespeichert')
})
it('starts clean again when another event is opened', async () => {
eventsStore.openPanel('e1')
const wrapper = mountPanel()
await flush(1)
eventsStore.ghostTitle = 'geändert'
await flush(1)
eventsStore.saveGhostNow()
await flush()
expect(wrapper.find('.event-panel__save-state').text()).toBe('Gespeichert')
eventsStore.openPanel('e1')
await flush(1)
expect(wrapper.find('.event-panel__save-state').exists()).toBe(false)
})
})