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>
270 lines
7.9 KiB
JavaScript
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)
|
|
})
|
|
})
|