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 @@