From 62f31dbde9c0ef1a0f6ae6cd010c07e74a9c50c6 Mon Sep 17 00:00:00 2001
From: Art Furrer <2823246+bonham@users.noreply.github.com>
Date: Wed, 8 Apr 2026 22:54:09 +0200
Subject: [PATCH 1/6] Enable framework to show errors in toast message Fixes
#136
---
client/components.d.ts | 4 +
client/src/App.vue | 1 +
client/src/components/ErrorIndicator.vue | 111 +++++
client/src/stores/errorstore.ts | 83 ++++
client/src/views/TrackMapPage.vue | 5 +-
.../unit/components/ErrorIndicator.spec.ts | 380 ++++++++++++++++++
client/tests/unit/stores/errorstore.spec.ts | 317 +++++++++++++++
7 files changed, 899 insertions(+), 2 deletions(-)
create mode 100644 client/src/components/ErrorIndicator.vue
create mode 100644 client/src/stores/errorstore.ts
create mode 100644 client/tests/unit/components/ErrorIndicator.spec.ts
create mode 100644 client/tests/unit/stores/errorstore.spec.ts
diff --git a/client/components.d.ts b/client/components.d.ts
index c0728ab..8369ee5 100644
--- a/client/components.d.ts
+++ b/client/components.d.ts
@@ -14,11 +14,15 @@ declare module 'vue' {
AuthOrchestrator: typeof import('./src/components/auth/AuthOrchestrator.vue')['default']
DropField: typeof import('./src/components/DropField.vue')['default']
EditableText: typeof import('./src/components/EditableText.vue')['default']
+ ErrorIndicator: typeof import('./src/components/ErrorIndicator.vue')['default']
IBiArrowDownCircleFill: typeof import('~icons/bi/arrow-down-circle-fill')['default']
IBiArrowLeft: typeof import('~icons/bi/arrow-left')['default']
IBiArrowRightCircle: typeof import('~icons/bi/arrow-right-circle')['default']
+ IBiCheck2: typeof import('~icons/bi/check2')['default']
IBiChevronRight: typeof import('~icons/bi/chevron-right')['default']
+ IBiClipboard: typeof import('~icons/bi/clipboard')['default']
IBiCompassFill: typeof import('~icons/bi/compass-fill')['default']
+ IBiExclamationTriangleFill: typeof import('~icons/bi/exclamation-triangle-fill')['default']
IBiPencilFill: typeof import('~icons/bi/pencil-fill')['default']
IBiTrash: typeof import('~icons/bi/trash')['default']
LoginFailureModal: typeof import('./src/components/auth/LoginFailureModal.vue')['default']
diff --git a/client/src/App.vue b/client/src/App.vue
index 731faf5..bdcdd28 100644
--- a/client/src/App.vue
+++ b/client/src/App.vue
@@ -12,6 +12,7 @@ onUnmounted(() => { userLoginStore.stopSessionHeartbeat() })
+
diff --git a/client/src/stores/errorstore.ts b/client/src/stores/errorstore.ts
new file mode 100644
index 0000000..f9156c5
--- /dev/null
+++ b/client/src/stores/errorstore.ts
@@ -0,0 +1,83 @@
+/**
+ * Error reporting store + utility
+ *
+ * HOW TO REPORT AN ERROR (preferred)
+ * ------------------------------------
+ * Import and call `reportError()` anywhere in the app:
+ *
+ * import { reportError } from '@/stores/errorstore'
+ *
+ * reportError('Failed to load track') // plain string
+ * reportError('Request failed:', new Error(...)) // string + Error object
+ * reportError(err) // bare Error object
+ *
+ * `reportError` forwards its arguments to `console.error` as well,
+ * so existing debugging workflows are unaffected.
+ *
+ * HOW TO ADD AN ERROR WITHOUT console.error (low-level)
+ * -------------------------------------------------------
+ * Use the store directly when you want to surface a UI message without
+ * a console entry, or when you already have a pre-formatted string:
+ *
+ * import { useErrorStore } from '@/stores/errorstore'
+ *
+ * const errorStore = useErrorStore()
+ * errorStore.addError('Session expired')
+ * errorStore.addError('Upload failed', stack) // optional stack/detail string
+ *
+ * UI COMPONENT
+ * ------------
+ * `` is mounted once in App.vue. It watches the store
+ * reactively and shows a floating badge whenever errors are present.
+ * No further wiring is needed — just call reportError() or addError().
+ */
+import { ref, computed } from 'vue'
+import { defineStore } from 'pinia'
+
+export interface ErrorEntry {
+ id: number
+ message: string
+ timestamp: Date
+ detail?: string
+}
+
+const MAX_ERRORS = 200 // oldest entry is evicted when the cap is reached
+let _nextId = 1 // module-level counter; survives store resets
+
+const useErrorStore = defineStore('errorstore', () => {
+ const errors = ref([])
+
+ function addError(message: string, detail?: string) {
+ if (errors.value.length >= MAX_ERRORS) errors.value.shift()
+ errors.value.push({ id: _nextId++, message, timestamp: new Date(), detail })
+ }
+
+ function dismissError(id: number) {
+ const idx = errors.value.findIndex(e => e.id === id)
+ if (idx !== -1) errors.value.splice(idx, 1)
+ }
+
+ function dismissAll() {
+ errors.value = []
+ }
+
+ const errorCount = computed(() => errors.value.length)
+ const hasErrors = computed(() => errors.value.length > 0)
+
+ return { errors, errorCount, hasErrors, addError, dismissError, dismissAll }
+})
+
+/** Drop-in replacement for console.error() that also surfaces the error to the UI.
+ * Call this for errors that are meaningful to the end user. */
+function reportError(...args: unknown[]): void {
+ console.error(...args)
+ const message = args.map(a =>
+ typeof a === 'string' ? a
+ : a instanceof Error ? `${a.name}: ${a.message}`
+ : (() => { try { return JSON.stringify(a) } catch { return String(a) } })()
+ ).join(' ')
+ const detail = (args.find((a): a is Error => a instanceof Error))?.stack
+ useErrorStore().addError(message, detail)
+}
+
+export { useErrorStore, reportError }
diff --git a/client/src/views/TrackMapPage.vue b/client/src/views/TrackMapPage.vue
index 622164f..f401c47 100644
--- a/client/src/views/TrackMapPage.vue
+++ b/client/src/views/TrackMapPage.vue
@@ -28,6 +28,7 @@ import MapComponent from '@/components/MapComponent.vue'
import { getTrackYears } from '@/lib/trackServices'
import { useMapStateStore } from '@/stores/mapstate'
import { useConfigStore } from '@/stores/configstore'
+import { reportError } from '@/stores/errorstore'
import { ref } from 'vue'
@@ -81,12 +82,12 @@ configStore.loadConfig(props.sid)
if (mostRecentYear !== undefined) {
loadTracksOfYear(mostRecentYear, true)
} else {
- console.log("mostRecentYear is undefined")
+ reportError("mostRecentYear is undefined")
}
}
}
})
- .catch((e) => console.error("Error on initial load", e))
+ .catch((e) => reportError("Error on initial load", e))
/**
* Fetch available years for tracks and initialize button states
diff --git a/client/tests/unit/components/ErrorIndicator.spec.ts b/client/tests/unit/components/ErrorIndicator.spec.ts
new file mode 100644
index 0000000..5193a52
--- /dev/null
+++ b/client/tests/unit/components/ErrorIndicator.spec.ts
@@ -0,0 +1,380 @@
+import { render, fireEvent, waitFor } from '@testing-library/vue'
+import { within } from '@testing-library/dom'
+import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
+import { setActivePinia, createPinia } from 'pinia'
+import { nextTick } from 'vue'
+import ErrorIndicator from '@/components/ErrorIndicator.vue'
+import { useErrorStore } from '@/stores/errorstore'
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+vi.mock('bootstrap-vue-next', () => ({
+ BModal: {
+ name: 'BModal',
+ props: {
+ modelValue: { type: Boolean, default: false },
+ title: { type: String, default: '' },
+ },
+ emits: ['update:modelValue'],
+ template: `
+
+
+
+
+ `,
+ },
+ BButton: {
+ name: 'BButton',
+ props: { variant: String },
+ emits: ['click'],
+ template: ``,
+ },
+}))
+
+// ---------------------------------------------------------------------------
+// Clipboard stub
+// ---------------------------------------------------------------------------
+
+const mockWriteText = vi.fn<(text: string) => Promise>().mockResolvedValue(undefined)
+
+beforeEach(() => {
+ mockWriteText.mockClear()
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText: mockWriteText },
+ configurable: true,
+ writable: true,
+ })
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
+// ---------------------------------------------------------------------------
+// Render helper
+// Errors are pre-populated BEFORE render so the component mounts with the
+// correct state already in the store (avoids nextTick() gaps in most tests).
+// ---------------------------------------------------------------------------
+
+function renderIndicator(initialErrors: { message: string; detail?: string }[] = []) {
+ const pinia = createPinia()
+ setActivePinia(pinia)
+ const store = useErrorStore()
+ for (const { message, detail } of initialErrors) store.addError(message, detail)
+ const result = render(ErrorIndicator, { global: { plugins: [pinia] } })
+ return { ...result, store }
+}
+
+// Helper: open the error overlay by clicking the FAB.
+async function openOverlay(getByRole: ReturnType['getByRole']) {
+ await fireEvent.click(getByRole('button', { name: /error/i }))
+}
+
+// ---------------------------------------------------------------------------
+// FAB (floating action button)
+// ---------------------------------------------------------------------------
+
+describe('ErrorIndicator – FAB', () => {
+ test('no button is rendered when the store is empty', () => {
+ const { queryByRole } = renderIndicator()
+ expect(queryByRole('button')).not.toBeInTheDocument()
+ })
+
+ test('button is rendered when the store has at least one error', () => {
+ const { getByRole } = renderIndicator([{ message: 'network error' }])
+ expect(getByRole('button', { name: /error/i })).toBeInTheDocument()
+ })
+
+ test('button appears reactively after an error is added post-render', async () => {
+ const { queryByRole, store } = renderIndicator()
+ expect(queryByRole('button')).not.toBeInTheDocument()
+
+ store.addError('late arrival')
+ await nextTick()
+
+ expect(queryByRole('button', { name: /error/i })).toBeInTheDocument()
+ })
+
+ test('aria-label includes the error count', () => {
+ const { getByRole } = renderIndicator([
+ { message: 'e1' },
+ { message: 'e2' },
+ { message: 'e3' },
+ ])
+ expect(getByRole('button', { name: /3 error/i })).toBeInTheDocument()
+ })
+
+ test('badge shows the numeric count for a small number of errors', () => {
+ const { getByText } = renderIndicator([
+ { message: 'a' },
+ { message: 'b' },
+ { message: 'c' },
+ ])
+ expect(getByText('3')).toBeInTheDocument()
+ })
+
+ test('badge shows "99+" when there are exactly 100 errors', () => {
+ const errors = Array.from({ length: 100 }, (_, i) => ({ message: `msg-${i}` }))
+ const { getByText } = renderIndicator(errors)
+ expect(getByText('99+')).toBeInTheDocument()
+ })
+
+ test('badge shows "99+" when there are more than 100 errors', () => {
+ const errors = Array.from({ length: 150 }, (_, i) => ({ message: `msg-${i}` }))
+ const { getByText } = renderIndicator(errors)
+ expect(getByText('99+')).toBeInTheDocument()
+ })
+
+ test('clicking the button opens the error overlay', async () => {
+ const { getByRole, getByText } = renderIndicator([{ message: 'visible error' }])
+ await openOverlay(getByRole)
+ expect(getByText('visible error')).toBeInTheDocument()
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Error overlay (main modal)
+// ---------------------------------------------------------------------------
+
+describe('ErrorIndicator – error overlay', () => {
+ test('error messages are NOT visible before the FAB is clicked', () => {
+ const { queryByText } = renderIndicator([{ message: 'hidden message' }])
+ expect(queryByText('hidden message')).not.toBeInTheDocument()
+ })
+
+ test('all error messages are listed after opening', async () => {
+ const { getByRole, getByText } = renderIndicator([
+ { message: 'first error' },
+ { message: 'second error' },
+ ])
+ await openOverlay(getByRole)
+ expect(getByText('first error')).toBeInTheDocument()
+ expect(getByText('second error')).toBeInTheDocument()
+ })
+
+ test('the timestamp of each error is visible', async () => {
+ const { getByRole, store } = renderIndicator([{ message: 'ts message' }])
+ const expectedTime = store.errors[0]!.timestamp.toLocaleTimeString()
+
+ await openOverlay(getByRole)
+
+ expect(document.body.textContent).toContain(expectedTime)
+ })
+
+ test('detail text is shown when the entry has a detail field', async () => {
+ const { getByRole, getByText } = renderIndicator([
+ { message: 'main msg', detail: 'stack line 1\nstack line 2' },
+ ])
+ await openOverlay(getByRole)
+ expect(getByText(/stack line 1/)).toBeInTheDocument()
+ })
+
+ test('detail is not rendered as "undefined" when the entry has no detail', async () => {
+ const { getByRole, queryByText } = renderIndicator([{ message: 'no detail' }])
+ await openOverlay(getByRole)
+ expect(queryByText('undefined')).not.toBeInTheDocument()
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Dismiss individual error
+// ---------------------------------------------------------------------------
+
+describe('ErrorIndicator – dismiss individual', () => {
+ test('clicking the dismiss button removes that entry from the list', async () => {
+ const { getByRole, queryByText, getByText } = renderIndicator([
+ { message: 'gone soon' },
+ { message: 'stays here' },
+ ])
+ await openOverlay(getByRole)
+ expect(getByText('gone soon')).toBeInTheDocument()
+
+ const dismissBtns = document.querySelectorAll('button[aria-label="Dismiss"]')
+ await fireEvent.click(dismissBtns[0]!) // first entry = 'gone soon'
+
+ expect(queryByText('gone soon')).not.toBeInTheDocument()
+ expect(getByText('stays here')).toBeInTheDocument()
+ })
+
+ test('remaining error count is correct after dismissing one', async () => {
+ const { getByRole, store } = renderIndicator([
+ { message: 'a' },
+ { message: 'b' },
+ { message: 'c' },
+ ])
+ await openOverlay(getByRole)
+
+ const dismissBtns = document.querySelectorAll('button[aria-label="Dismiss"]')
+ await fireEvent.click(dismissBtns[0]!) // remove 'a'
+
+ expect(store.errorCount).toBe(2)
+ expect(store.errors.map(e => e.message)).toEqual(['b', 'c'])
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Clear All flow
+// ---------------------------------------------------------------------------
+
+describe('ErrorIndicator – clear all', () => {
+ test('clicking "Clear All" in the overlay opens a confirmation dialog', async () => {
+ const { getByRole, getByText } = renderIndicator([{ message: 'some error' }])
+ await openOverlay(getByRole)
+ await fireEvent.click(getByRole('button', { name: 'Clear All' }))
+
+ expect(getByText(/Dismiss all/)).toBeInTheDocument()
+ })
+
+ test('confirmation dialog shows the current error count', async () => {
+ const { getByRole, getByText } = renderIndicator([
+ { message: 'err-1' },
+ { message: 'err-2' },
+ ])
+ await openOverlay(getByRole)
+ await fireEvent.click(getByRole('button', { name: 'Clear All' }))
+
+ expect(getByText(/Dismiss all 2/)).toBeInTheDocument()
+ })
+
+ test('clicking "Cancel" in the confirmation keeps all errors', async () => {
+ const { getByRole, store } = renderIndicator([{ message: 'survives' }])
+ await openOverlay(getByRole)
+ await fireEvent.click(getByRole('button', { name: 'Clear All' }))
+ await fireEvent.click(getByRole('button', { name: 'Cancel' }))
+
+ expect(store.errorCount).toBe(1)
+ expect(store.errors[0]!.message).toBe('survives')
+ })
+
+ test('confirming "Clear All" empties the error store', async () => {
+ const { getByRole, container, store } = renderIndicator([
+ { message: 'gone-1' },
+ { message: 'gone-2' },
+ ])
+ await openOverlay(getByRole)
+ await fireEvent.click(getByRole('button', { name: 'Clear All' }))
+
+ // Two "Clear All" buttons are in the DOM at this point: one from the main
+ // modal footer, one from the confirm dialog. Scope to the confirm dialog.
+ const confirmModal = container.querySelector('[data-modal-title="Clear all errors?"]')!
+ await fireEvent.click(within(confirmModal).getByRole('button', { name: 'Clear All' }))
+
+ expect(store.errorCount).toBe(0)
+ expect(store.hasErrors).toBe(false)
+ })
+
+ test('confirming "Clear All" hides both the overlay and the confirmation dialog', async () => {
+ const { getByRole, queryByText, container } = renderIndicator([
+ { message: 'will-disappear' },
+ ])
+ await openOverlay(getByRole)
+ await fireEvent.click(getByRole('button', { name: 'Clear All' }))
+
+ const confirmModal = container.querySelector('[data-modal-title="Clear all errors?"]')!
+ await fireEvent.click(within(confirmModal).getByRole('button', { name: 'Clear All' }))
+
+ expect(queryByText('will-disappear')).not.toBeInTheDocument()
+ expect(queryByText(/Dismiss all/)).not.toBeInTheDocument()
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Clipboard – copy single message
+// ---------------------------------------------------------------------------
+
+describe('ErrorIndicator – copy single message', () => {
+ test('clicking the copy button invokes the clipboard API', async () => {
+ const { getByRole, getAllByTitle } = renderIndicator([{ message: 'clipboard-test' }])
+ await openOverlay(getByRole)
+ await fireEvent.click(getAllByTitle('Copy message')[0]!)
+
+ await waitFor(() => expect(mockWriteText).toHaveBeenCalledTimes(1))
+ })
+
+ test('clipboard receives [ISO-timestamp] message format', async () => {
+ const { getByRole, getAllByTitle, store } = renderIndicator([{ message: 'format check' }])
+ const entry = store.errors[0]!
+ await openOverlay(getByRole)
+ await fireEvent.click(getAllByTitle('Copy message')[0]!)
+
+ const expected = `[${entry.timestamp.toISOString()}] ${entry.message}`
+ await waitFor(() => expect(mockWriteText).toHaveBeenCalledWith(expected))
+ })
+
+ test('clipboard text includes the detail on a new line when present', async () => {
+ const { getByRole, getAllByTitle, store } = renderIndicator([
+ { message: 'err with detail', detail: 'Error: inner\n at foo.ts:10' },
+ ])
+ const entry = store.errors[0]!
+ await openOverlay(getByRole)
+ await fireEvent.click(getAllByTitle('Copy message')[0]!)
+
+ const expected = `[${entry.timestamp.toISOString()}] ${entry.message}\n${entry.detail}`
+ await waitFor(() => expect(mockWriteText).toHaveBeenCalledWith(expected))
+ })
+
+ test('clipboard text does not end with a newline when there is no detail', async () => {
+ const { getByRole, getAllByTitle, store } = renderIndicator([{ message: 'clean' }])
+ const entry = store.errors[0]!
+ await openOverlay(getByRole)
+ await fireEvent.click(getAllByTitle('Copy message')[0]!)
+
+ await waitFor(() => {
+ const arg = mockWriteText.mock.lastCall![0]
+ expect(arg).toBe(`[${entry.timestamp.toISOString()}] ${entry.message}`)
+ expect(arg.endsWith('\n')).toBe(false)
+ })
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Clipboard – copy all messages
+// ---------------------------------------------------------------------------
+
+describe('ErrorIndicator – copy all messages', () => {
+ test('Copy All writes all entries joined by a separator', async () => {
+ const { getByRole, getByText, store } = renderIndicator([
+ { message: 'first' },
+ { message: 'second' },
+ ])
+ const [e1, e2] = store.errors
+ await openOverlay(getByRole)
+ await fireEvent.click(getByText('Copy All'))
+
+ const expected =
+ `[${e1!.timestamp.toISOString()}] ${e1!.message}` +
+ '\n\n---\n\n' +
+ `[${e2!.timestamp.toISOString()}] ${e2!.message}`
+
+ await waitFor(() => expect(mockWriteText).toHaveBeenCalledWith(expected))
+ })
+
+ test('Copy All for a single entry produces no separator', async () => {
+ const { getByRole, getByText, store } = renderIndicator([{ message: 'solo entry' }])
+ const entry = store.errors[0]!
+ await openOverlay(getByRole)
+ await fireEvent.click(getByText('Copy All'))
+
+ await waitFor(() => {
+ const arg = mockWriteText.mock.lastCall![0]
+ expect(arg).toBe(`[${entry.timestamp.toISOString()}] ${entry.message}`)
+ expect(arg).not.toContain('---')
+ })
+ })
+
+ test('Copy All includes detail lines for entries that have them', async () => {
+ const { getByRole, getByText, store } = renderIndicator([
+ { message: 'err', detail: 'Error: boom\n at bar.ts:5' },
+ ])
+ const entry = store.errors[0]!
+ await openOverlay(getByRole)
+ await fireEvent.click(getByText('Copy All'))
+
+ await waitFor(() => {
+ const arg = mockWriteText.mock.lastCall![0]
+ expect(arg).toContain(entry.detail!)
+ })
+ })
+})
diff --git a/client/tests/unit/stores/errorstore.spec.ts b/client/tests/unit/stores/errorstore.spec.ts
new file mode 100644
index 0000000..206a296
--- /dev/null
+++ b/client/tests/unit/stores/errorstore.spec.ts
@@ -0,0 +1,317 @@
+import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
+import { setActivePinia, createPinia } from 'pinia'
+import { useErrorStore, reportError } from '@/stores/errorstore'
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Returns a fresh store for each test (pinia is reset in beforeEach). */
+function getStore() {
+ return useErrorStore()
+}
+
+// ---------------------------------------------------------------------------
+// useErrorStore
+// ---------------------------------------------------------------------------
+
+describe('useErrorStore', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ })
+
+ // -----------------------------------------------------------------------
+ // Initial state
+ // -----------------------------------------------------------------------
+
+ describe('initial state', () => {
+ test('errors list is empty', () => {
+ expect(getStore().errors).toHaveLength(0)
+ })
+
+ test('errorCount is 0', () => {
+ expect(getStore().errorCount).toBe(0)
+ })
+
+ test('hasErrors is false', () => {
+ expect(getStore().hasErrors).toBe(false)
+ })
+ })
+
+ // -----------------------------------------------------------------------
+ // addError
+ // -----------------------------------------------------------------------
+
+ describe('addError', () => {
+ test('appends an entry with the given message', () => {
+ const store = getStore()
+ store.addError('something broke')
+ expect(store.errors[0]!.message).toBe('something broke')
+ })
+
+ test('assigns a timestamp close to the moment it was called', () => {
+ const before = Date.now()
+ const store = getStore()
+ store.addError('ts-test')
+ const after = Date.now()
+ const ts = store.errors[0]!.timestamp.getTime()
+ expect(ts).toBeGreaterThanOrEqual(before)
+ expect(ts).toBeLessThanOrEqual(after)
+ })
+
+ test('stores the detail string when provided', () => {
+ const store = getStore()
+ store.addError('msg', 'detailed stack trace')
+ expect(store.errors[0]!.detail).toBe('detailed stack trace')
+ })
+
+ test('leaves detail undefined when not provided', () => {
+ const store = getStore()
+ store.addError('msg')
+ expect(store.errors[0]!.detail).toBeUndefined()
+ })
+
+ test('each successive call produces a distinct id', () => {
+ const store = getStore()
+ store.addError('first')
+ store.addError('second')
+ expect(store.errors[0]!.id).not.toBe(store.errors[1]!.id)
+ })
+
+ test('increments errorCount', () => {
+ const store = getStore()
+ store.addError('a')
+ expect(store.errorCount).toBe(1)
+ store.addError('b')
+ expect(store.errorCount).toBe(2)
+ })
+
+ test('sets hasErrors to true as soon as one entry exists', () => {
+ const store = getStore()
+ store.addError('x')
+ expect(store.hasErrors).toBe(true)
+ })
+
+ test('multiple errors are appended in order', () => {
+ const store = getStore()
+ store.addError('first')
+ store.addError('second')
+ store.addError('third')
+ expect(store.errors.map(e => e.message)).toEqual(['first', 'second', 'third'])
+ })
+ })
+
+ // -----------------------------------------------------------------------
+ // MAX_ERRORS cap (200)
+ // -----------------------------------------------------------------------
+
+ describe('cap at 200 errors', () => {
+ test('count stays at 200 when 201 errors are added', () => {
+ const store = getStore()
+ for (let i = 1; i <= 201; i++) store.addError(`msg-${i}`)
+ expect(store.errors).toHaveLength(200)
+ })
+
+ test('the first (oldest) entry is evicted, not the newest', () => {
+ const store = getStore()
+ for (let i = 1; i <= 200; i++) store.addError(`msg-${i}`)
+ const oldestMessage = store.errors[0]!.message
+
+ store.addError('msg-201')
+
+ expect(store.errors[0]!.message).not.toBe(oldestMessage)
+ expect(store.errors[store.errors.length - 1]!.message).toBe('msg-201')
+ })
+
+ test('does not evict when adding exactly the 200th entry', () => {
+ const store = getStore()
+ for (let i = 1; i <= 199; i++) store.addError(`msg-${i}`)
+ const firstMessageBefore = store.errors[0]!.message
+
+ store.addError('msg-200')
+
+ expect(store.errors).toHaveLength(200)
+ expect(store.errors[0]!.message).toBe(firstMessageBefore)
+ })
+ })
+
+ // -----------------------------------------------------------------------
+ // dismissError
+ // -----------------------------------------------------------------------
+
+ describe('dismissError', () => {
+ test('removes the entry with the matching id', () => {
+ const store = getStore()
+ store.addError('keep')
+ store.addError('remove')
+ const idToRemove = store.errors[1]!.id
+
+ store.dismissError(idToRemove)
+
+ expect(store.errors).toHaveLength(1)
+ expect(store.errors[0]!.message).toBe('keep')
+ })
+
+ test('leaves all other entries intact when removing from the middle', () => {
+ const store = getStore()
+ store.addError('a')
+ store.addError('b')
+ store.addError('c')
+ const middleId = store.errors[1]!.id
+
+ store.dismissError(middleId)
+
+ expect(store.errors.map(e => e.message)).toEqual(['a', 'c'])
+ })
+
+ test('is a no-op for an id that does not exist', () => {
+ const store = getStore()
+ store.addError('stays')
+ const countBefore = store.errorCount
+
+ store.dismissError(-999)
+
+ expect(store.errorCount).toBe(countBefore)
+ expect(store.errors[0]!.message).toBe('stays')
+ })
+
+ test('removing the last entry resets hasErrors to false', () => {
+ const store = getStore()
+ store.addError('only one')
+ const id = store.errors[0]!.id
+
+ store.dismissError(id)
+
+ expect(store.hasErrors).toBe(false)
+ expect(store.errorCount).toBe(0)
+ })
+ })
+
+ // -----------------------------------------------------------------------
+ // dismissAll
+ // -----------------------------------------------------------------------
+
+ describe('dismissAll', () => {
+ test('empties the error list', () => {
+ const store = getStore()
+ store.addError('a')
+ store.addError('b')
+
+ store.dismissAll()
+
+ expect(store.errors).toHaveLength(0)
+ })
+
+ test('resets errorCount to 0', () => {
+ const store = getStore()
+ store.addError('x')
+
+ store.dismissAll()
+
+ expect(store.errorCount).toBe(0)
+ })
+
+ test('resets hasErrors to false', () => {
+ const store = getStore()
+ store.addError('x')
+
+ store.dismissAll()
+
+ expect(store.hasErrors).toBe(false)
+ })
+
+ test('is a no-op on an already empty store', () => {
+ const store = getStore()
+
+ expect(() => store.dismissAll()).not.toThrow()
+ expect(store.errors).toHaveLength(0)
+ })
+ })
+})
+
+// ---------------------------------------------------------------------------
+// reportError
+// ---------------------------------------------------------------------------
+
+describe('reportError', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ test('forwards all arguments to console.error', () => {
+ reportError('something', 'extra context')
+ expect(vi.mocked(console.error)).toHaveBeenCalledWith('something', 'extra context')
+ })
+
+ test('adds exactly one entry to the store', () => {
+ const store = useErrorStore()
+ reportError('boom')
+ expect(store.errors).toHaveLength(1)
+ })
+
+ test('stores a plain string message verbatim', () => {
+ const store = useErrorStore()
+ reportError('network timeout')
+ expect(store.errors[0]!.message).toBe('network timeout')
+ })
+
+ test('joins multiple string arguments with a space', () => {
+ const store = useErrorStore()
+ reportError('Error in', 'fetchData:', 'timed out')
+ expect(store.errors[0]!.message).toBe('Error in fetchData: timed out')
+ })
+
+ test('formats an Error object as "Name: message"', () => {
+ const store = useErrorStore()
+ reportError(new TypeError('expected string, got number'))
+ expect(store.errors[0]!.message).toBe('TypeError: expected string, got number')
+ })
+
+ test('stores the Error stack as the detail field', () => {
+ const store = useErrorStore()
+ const err = new Error('with stack')
+ reportError(err)
+ expect(store.errors[0]!.detail).toBe(err.stack)
+ })
+
+ test('concatenates a prefix string with a following Error', () => {
+ const store = useErrorStore()
+ reportError('failed to load track:', new RangeError('id out of range'))
+ expect(store.errors[0]!.message).toBe('failed to load track: RangeError: id out of range')
+ })
+
+ test('detail is undefined when no Error is among the arguments', () => {
+ const store = useErrorStore()
+ reportError('plain string only')
+ expect(store.errors[0]!.detail).toBeUndefined()
+ })
+
+ test('serializes a plain object as JSON', () => {
+ const store = useErrorStore()
+ reportError({ status: 503, url: '/api/tracks' })
+ expect(store.errors[0]!.message).toBe('{"status":503,"url":"/api/tracks"}')
+ })
+
+ test('does not throw for a non-JSON-serializable value (circular reference)', () => {
+ const store = useErrorStore()
+ const circular: Record = {}
+ circular.self = circular
+
+ expect(() => reportError(circular)).not.toThrow()
+ expect(store.errors).toHaveLength(1)
+ expect(typeof store.errors[0]!.message).toBe('string')
+ })
+
+ test('uses the first Error stack even when multiple Errors are passed', () => {
+ const store = useErrorStore()
+ const err1 = new Error('first')
+ const err2 = new Error('second')
+ reportError(err1, err2)
+ expect(store.errors[0]!.detail).toBe(err1.stack)
+ })
+})
From f19bba2abf01636ca8fc79806e258e761e895249 Mon Sep 17 00:00:00 2001
From: Art Furrer <2823246+bonham@users.noreply.github.com>
Date: Sun, 12 Apr 2026 19:55:40 +0200
Subject: [PATCH 2/6] error indicator layout fix
---
client/src/App.vue | 1 -
client/src/components/ErrorIndicator.vue | 37 ++++++++++++++++++--
client/src/components/TrackManagerNavBar.vue | 3 +-
client/src/views/TrackMapPage.vue | 1 +
4 files changed, 37 insertions(+), 5 deletions(-)
diff --git a/client/src/App.vue b/client/src/App.vue
index bdcdd28..731faf5 100644
--- a/client/src/App.vue
+++ b/client/src/App.vue
@@ -12,7 +12,6 @@ onUnmounted(() => { userLoginStore.stopSessionHeartbeat() })
-
diff --git a/client/src/components/TrackManagerNavBar.vue b/client/src/components/TrackManagerNavBar.vue
index bc5146c..5262852 100644
--- a/client/src/components/TrackManagerNavBar.vue
+++ b/client/src/components/TrackManagerNavBar.vue
@@ -2,7 +2,7 @@
-