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

139 lines
3.8 KiB
JavaScript

import { nextTick } from 'vue'
/**
* A promise whose settling the test controls, used to hold an API response
* open across a user switch.
*/
export function deferred() {
let resolve
let reject
const promise = new Promise((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
/**
* Lets Vue's watchers, the promise chains behind them and the IndexedDB
* writes settle. IndexedDB resolves on real event-loop turns, so flushing
* microtasks alone is not enough.
*/
export async function flush(times = 6) {
for (let i = 0; i < times; i++) {
await nextTick()
await new Promise(resolve => setTimeout(resolve, 0))
}
}
/**
* Waits until `predicate` holds, flushing between checks. The sync queue
* moves through several IndexedDB turns per item, and how many a run needs
* depends on machine load; polling keeps the tests from guessing.
*/
export async function waitFor(predicate, { rounds = 60, label = 'condition' } = {}) {
for (let i = 0; i < rounds; i++) {
if (predicate()) return
await flush(1)
}
if (!predicate()) throw new Error(`waitFor: ${label} not met in time`)
}
/**
* Lets a store's sync queue finish what it is sending. Called after a test:
* a request still in flight when the test ends would land in the next
* test's API mock. The first flushes give a write that was just scheduled
* (closing the panel persists the draft) time to reach the queue at all.
*/
export async function drainQueue(store) {
await flush(3)
await waitFor(
() => store.pendingSyncCount === 0 && !store.isSyncing,
{ rounds: 100, label: 'queue drained' }
).catch(() => {})
}
/**
* Routes mocked `apiJson` calls by URL.
*
* Several stores talk to the API during start-up — the settings store fires
* `/settings` alongside the events store's `/events` — so responses have to
* be matched on the request, not on call order.
*/
export function createApiRouter() {
const handlers = []
const calls = []
function apiJson(url, options = {}) {
calls.push({ url, options })
const handler = handlers.find(entry => entry.matches(url, options))
if (!handler) {
return Promise.resolve({ data: [], links: { next: null } })
}
return handler.respond(url, options)
}
return {
apiJson,
calls,
/** Queue a response for the next matching request. */
once(matcher, respond) {
const entry = {
matches: (url, options) => toMatcher(matcher)(url, options) && !entry.used,
respond: (url, options) => {
entry.used = true
return typeof respond === 'function' ? respond(url, options) : Promise.resolve(respond)
}
}
handlers.push(entry)
return entry
},
/** Queue a response for every matching request. */
always(matcher, respond) {
handlers.push({
matches: toMatcher(matcher),
respond: (url, options) => (typeof respond === 'function' ? respond(url, options) : Promise.resolve(respond))
})
},
callsTo(matcher) {
const match = toMatcher(matcher)
return calls.filter(call => match(call.url, call.options))
}
}
}
function toMatcher(matcher) {
if (typeof matcher === 'function') return matcher
if (matcher instanceof RegExp) return url => matcher.test(url)
return url => url === matcher || url.startsWith(matcher)
}
/** A remote event payload in the shape the API returns. */
export function remoteEvent(overrides = {}) {
return {
id: 'e1',
title: 'Event',
date: '2025-01-01',
endDate: null,
location: '',
category: null,
people: [],
emotion: 0,
milestone: false,
customColor: null,
gradientPreset: null,
image: null,
keyImageTitle: '',
media: [],
note: '',
createdAt: 1,
updatedAt: 1,
...overrides
}
}
export function page(events) {
return { data: events, links: { next: null } }
}