diff --git a/frontend-modern/src/utils/__tests__/localStorage.test.ts b/frontend-modern/src/utils/__tests__/localStorage.test.ts new file mode 100644 index 0000000000..5ccab2460e --- /dev/null +++ b/frontend-modern/src/utils/__tests__/localStorage.test.ts @@ -0,0 +1,487 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createRoot } from 'solid-js'; +import { + createLocalStorageBooleanSignal, + createLocalStorageNumberSignal, + createLocalStorageStringSignal, + STORAGE_KEYS, +} from '@/utils/localStorage'; + +const SYNC_EVENT = 'pulse-localstorage-sync'; + +// createEffect defers its first run to a microtask; await this after setting up +// or mutating a signal so the sync-to-storage effect has flushed. +const flush = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +type AnySignal = [() => T, (v: T) => void]; + +const disposables: Array<() => void> = []; + +function makeSignal(factory: () => AnySignal): { + value: () => T; + setValue: (v: T) => void; + setValueAny: (v: unknown) => void; + dispose: () => void; +} { + let dispose!: () => void; + let value!: () => T; + let setValue!: (v: T) => void; + createRoot((d) => { + dispose = d; + [value, setValue] = factory() as AnySignal; + }); + const disposeFn = () => dispose(); + disposables.push(disposeFn); + return { + value: () => value(), + setValue: (v: T) => setValue(v), + setValueAny: (v: unknown) => (setValue as (v: unknown) => void)(v), + dispose: disposeFn, + }; +} + +function dispatchCustomSync(key: string, value: string | null) { + window.dispatchEvent( + new CustomEvent(SYNC_EVENT, { detail: { key, value } }), + ); +} + +// jsdom's StorageEvent constructor rejects non-jsdom Storage instances for +// storageArea (the shared in-memory storage from setup is a plain class). The +// handler only reads key/newValue/storageArea, so a plain Event with those +// props is sufficient and faithful for exercising the handler logic. +function dispatchStorageEvent(key: string, newValue: string | null, storageArea: Storage | null) { + const event = new Event('storage'); + Object.defineProperty(event, 'key', { value: key, configurable: true }); + Object.defineProperty(event, 'newValue', { value: newValue, configurable: true }); + Object.defineProperty(event, 'storageArea', { value: storageArea, configurable: true }); + window.dispatchEvent(event); +} + +describe('localStorage signals', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + while (disposables.length) disposables.pop()!(); + vi.restoreAllMocks(); + }); + + describe('STORAGE_KEYS', () => { + it('exposes the expected string keys', () => { + expect(STORAGE_KEYS.AUTH).toBe('pulse_auth'); + expect(STORAGE_KEYS.AUTH_USER).toBe('pulse_auth_user'); + expect(STORAGE_KEYS.THEME_PREFERENCE).toBe('pulseThemePreference'); + expect(STORAGE_KEYS.SIDEBAR_COLLAPSED).toBe('sidebarCollapsed'); + expect(STORAGE_KEYS.TEMPERATURE_UNIT).toBe('temperatureUnit'); + expect(STORAGE_KEYS.DEBUG_MODE).toBe('pulse_debug_mode'); + expect(STORAGE_KEYS.AUDIT_PAGE_SIZE).toBe('pulse-audit-page-size'); + }); + + it('exposes a broad set of keys across feature areas', () => { + expect(Object.keys(STORAGE_KEYS).length).toBeGreaterThan(40); + expect(STORAGE_KEYS.WORKLOADS_SEARCH_HISTORY).toBe('workloadsSearchHistory'); + expect(STORAGE_KEYS.GITHUB_STAR_SNOOZED_UNTIL).toBe('pulse-github-star-snoozed-until'); + }); + }); + + describe('createLocalStorageStringSignal', () => { + it('uses the default value when storage is empty', async () => { + const sig = makeSignal(() => createLocalStorageStringSignal('str-key', 'default')); + + expect(sig.value()).toBe('default'); + }); + + it('writes the default value to storage once the effect flushes', async () => { + const sig = makeSignal(() => createLocalStorageStringSignal('str-key', 'default')); + await flush(); + + expect(localStorage.getItem('str-key')).toBe('default'); + sig.dispose(); + }); + + it('defaults to an empty string when no default is provided', async () => { + const sig = makeSignal(() => createLocalStorageStringSignal('str-key')); + await flush(); + + expect(sig.value()).toBe(''); + expect(localStorage.getItem('str-key')).toBe(''); + }); + + it('reads a stored value synchronously', () => { + localStorage.setItem('str-key', 'stored'); + + const sig = makeSignal(() => createLocalStorageStringSignal('str-key', 'default')); + + expect(sig.value()).toBe('stored'); + }); + + it('writes to localStorage when the value changes', async () => { + const sig = makeSignal(() => createLocalStorageStringSignal('str-key', 'default')); + await flush(); + + sig.setValue('new-value'); + await flush(); + + expect(sig.value()).toBe('new-value'); + expect(localStorage.getItem('str-key')).toBe('new-value'); + }); + + it('persists an empty string', async () => { + const sig = makeSignal(() => createLocalStorageStringSignal('str-key', 'default')); + await flush(); + + sig.setValue(''); + await flush(); + + expect(localStorage.getItem('str-key')).toBe(''); + }); + + // NOTE: setting null/undefined does NOT persist a removal. The effect removes + // the item and broadcasts null, but the signal's own same-tab custom-event + // listener receives that null broadcast and reverts the signal to its + // defaultValue (applyRaw maps null -> defaultValue). The effect then writes + // the default back to storage. See GLM_REPORT.md (suspected bug). + it('reverts to the default when set to null (current behavior)', async () => { + const sig = makeSignal(() => createLocalStorageStringSignal('str-key', 'default')); + await flush(); + sig.setValue('changed'); + await flush(); + expect(localStorage.getItem('str-key')).toBe('changed'); + + sig.setValueAny(null); + await flush(); + + expect(sig.value()).toBe('default'); + expect(localStorage.getItem('str-key')).toBe('default'); + }); + + it('reverts to the default when set to undefined (current behavior)', async () => { + const sig = makeSignal(() => createLocalStorageStringSignal('str-key', 'default')); + await flush(); + sig.setValue('changed'); + await flush(); + expect(localStorage.getItem('str-key')).toBe('changed'); + + sig.setValueAny(undefined); + await flush(); + + expect(sig.value()).toBe('default'); + expect(localStorage.getItem('str-key')).toBe('default'); + }); + + it('broadcasts a sync event when the value changes', async () => { + const details: Array<{ key: string; value: string | null }> = []; + const handler = (e: Event) => { + const evt = e as CustomEvent<{ key: string; value: string | null }>; + details.push(evt.detail); + }; + window.addEventListener(SYNC_EVENT, handler); + + const sig = makeSignal(() => createLocalStorageStringSignal('broadcast-key', 'def')); + await flush(); + expect(details.at(-1)).toEqual({ key: 'broadcast-key', value: 'def' }); + + sig.setValue('updated'); + await flush(); + expect(details.at(-1)).toEqual({ key: 'broadcast-key', value: 'updated' }); + + sig.setValueAny(null); + await flush(); + // A null broadcast is emitted (the clear intent), but the same-tab + // self-sync reverts the signal to its default (suspected bug). + expect(details).toContainEqual({ key: 'broadcast-key', value: null }); + expect(details.at(-1)).toEqual({ key: 'broadcast-key', value: 'def' }); + + window.removeEventListener(SYNC_EVENT, handler); + }); + }); + + describe('createLocalStorageBooleanSignal', () => { + const parseCases: Array<[string, boolean]> = [ + ['true', true], + ['false', false], + ['1', false], + ['0', false], + ['TRUE', false], + ['True', false], + ['', false], + ['anything', false], + ]; + + it.each(parseCases)('parses stored %j as %s', (stored, expected) => { + localStorage.setItem('bool-key', stored); + + const sig = makeSignal(() => createLocalStorageBooleanSignal('bool-key', false)); + + expect(sig.value()).toBe(expected); + }); + + it('defaults to false when no default is given and storage is empty', async () => { + const sig = makeSignal(() => createLocalStorageBooleanSignal('bool-key')); + await flush(); + + expect(sig.value()).toBe(false); + expect(localStorage.getItem('bool-key')).toBe('false'); + }); + + it('defaults to true when provided and storage is empty', async () => { + const sig = makeSignal(() => createLocalStorageBooleanSignal('bool-key', true)); + await flush(); + + expect(sig.value()).toBe(true); + expect(localStorage.getItem('bool-key')).toBe('true'); + }); + + it.each([ + [true, 'true'], + [false, 'false'], + ])('writes %s to storage as %j', async (next, expected) => { + const sig = makeSignal(() => createLocalStorageBooleanSignal('bool-key', false)); + await flush(); + + sig.setValue(next); + await flush(); + + expect(localStorage.getItem('bool-key')).toBe(expected); + }); + + it('reverts to the default when set to null (current behavior)', async () => { + const sig = makeSignal(() => createLocalStorageBooleanSignal('bool-key', true)); + await flush(); + expect(localStorage.getItem('bool-key')).toBe('true'); + + sig.setValueAny(null); + await flush(); + + expect(sig.value()).toBe(true); + expect(localStorage.getItem('bool-key')).toBe('true'); + }); + }); + + describe('createLocalStorageNumberSignal', () => { + const defaultParseCases: Array<[string, number]> = [ + ['42', 42], + ['3.14', 3.14], + ['-5', -5], + ['0', 0], + ['1e3', 1000], + ['0x10', 16], + ['', 0], + [' ', 0], + ]; + + it.each(defaultParseCases)('parses stored %j as %s', (stored, expected) => { + localStorage.setItem('num-key', stored); + + const sig = makeSignal(() => createLocalStorageNumberSignal('num-key', 7)); + + expect(sig.value()).toBe(expected); + }); + + it.each([ + ['abc'], + ['Infinity'], + ['NaN'], + ])('falls back to the default when stored %j is not finite', (stored) => { + localStorage.setItem('num-key', stored); + + const sig = makeSignal(() => createLocalStorageNumberSignal('num-key', 7)); + + expect(sig.value()).toBe(7); + }); + + it('heals an invalid stored value by writing the default back to storage', async () => { + localStorage.setItem('num-key', 'abc'); + + const sig = makeSignal(() => createLocalStorageNumberSignal('num-key', 7)); + await flush(); + + expect(sig.value()).toBe(7); + expect(localStorage.getItem('num-key')).toBe('7'); + }); + + it('defaults to 0 when no default is given and storage is empty', async () => { + const sig = makeSignal(() => createLocalStorageNumberSignal('num-key')); + await flush(); + + expect(sig.value()).toBe(0); + expect(localStorage.getItem('num-key')).toBe('0'); + }); + + it.each([ + [99, '99'], + [0, '0'], + [-3.5, '-3.5'], + ])('writes %s to storage as %j', async (next, expected) => { + const sig = makeSignal(() => createLocalStorageNumberSignal('num-key', 0)); + await flush(); + + sig.setValue(next); + await flush(); + + expect(localStorage.getItem('num-key')).toBe(expected); + }); + + it('reverts to the default when set to null (current behavior)', async () => { + const sig = makeSignal(() => createLocalStorageNumberSignal('num-key', 5)); + await flush(); + expect(localStorage.getItem('num-key')).toBe('5'); + + sig.setValueAny(null); + await flush(); + + expect(sig.value()).toBe(5); + expect(localStorage.getItem('num-key')).toBe('5'); + }); + }); + + describe('cross-instance sync', () => { + it('keeps two signals for the same key in sync', async () => { + const a = makeSignal(() => createLocalStorageStringSignal('sync-key', 'def')); + await flush(); + const b = makeSignal(() => createLocalStorageStringSignal('sync-key', 'def')); + await flush(); + + expect(a.value()).toBe('def'); + expect(b.value()).toBe('def'); + + a.setValue('from-a'); + await flush(); + expect(b.value()).toBe('from-a'); + expect(localStorage.getItem('sync-key')).toBe('from-a'); + + b.setValue('from-b'); + await flush(); + expect(a.value()).toBe('from-b'); + }); + }); + + describe('storage event sync', () => { + it('updates the signal from a storage event for its key', async () => { + const sig = makeSignal(() => createLocalStorageStringSignal('se-key', 'def')); + await flush(); + + dispatchStorageEvent('se-key', 'from-storage', window.localStorage); + + expect(sig.value()).toBe('from-storage'); + }); + + it('ignores storage events for other keys', async () => { + const sig = makeSignal(() => createLocalStorageStringSignal('se-key', 'def')); + await flush(); + + dispatchStorageEvent('other-key', 'nope', window.localStorage); + + expect(sig.value()).toBe('def'); + }); + + it('ignores storage events from a different storage area', async () => { + const sig = makeSignal(() => createLocalStorageStringSignal('se-key', 'def')); + await flush(); + + dispatchStorageEvent('se-key', 'nope', null); + + expect(sig.value()).toBe('def'); + }); + + it('reverts to the default when a storage event clears the key', async () => { + localStorage.setItem('se-key', 'present'); + const sig = makeSignal(() => createLocalStorageStringSignal('se-key', 'def')); + await flush(); + expect(sig.value()).toBe('present'); + + dispatchStorageEvent('se-key', null, window.localStorage); + + expect(sig.value()).toBe('def'); + }); + }); + + describe('custom sync event', () => { + it('updates the signal from a custom sync event for its key', async () => { + const sig = makeSignal(() => createLocalStorageStringSignal('cs-key', 'def')); + await flush(); + + dispatchCustomSync('cs-key', 'via-custom'); + + expect(sig.value()).toBe('via-custom'); + }); + + it('ignores custom sync events for other keys', async () => { + const sig = makeSignal(() => createLocalStorageStringSignal('cs-key', 'def')); + await flush(); + + dispatchCustomSync('other-key', 'nope'); + + expect(sig.value()).toBe('def'); + }); + + it('ignores custom sync events without detail', async () => { + const sig = makeSignal(() => createLocalStorageStringSignal('cs-key', 'def')); + await flush(); + + window.dispatchEvent(new CustomEvent(SYNC_EVENT)); + + expect(sig.value()).toBe('def'); + }); + + it('reverts to the default when the custom event value is null', async () => { + localStorage.setItem('cs-key', 'present'); + const sig = makeSignal(() => createLocalStorageStringSignal('cs-key', 'def')); + await flush(); + expect(sig.value()).toBe('present'); + + dispatchCustomSync('cs-key', null); + + expect(sig.value()).toBe('def'); + }); + + it('does not change when the custom event carries the current value', async () => { + const sig = makeSignal(() => createLocalStorageStringSignal('cs-key', 'def')); + await flush(); + + dispatchCustomSync('cs-key', 'def'); + + expect(sig.value()).toBe('def'); + }); + }); + + describe('cleanup', () => { + it('stops listening for sync events after disposal', async () => { + const sig = makeSignal(() => createLocalStorageStringSignal('cu-key', 'def')); + await flush(); + expect(sig.value()).toBe('def'); + + sig.dispose(); + + dispatchCustomSync('cu-key', 'after-dispose'); + expect(sig.value()).toBe('def'); + }); + }); + + describe('broadcast error handling', () => { + it('does not throw when dispatching the sync event fails', async () => { + const dispatchSpy = vi.spyOn(window, 'dispatchEvent').mockImplementation((event: Event) => { + if (event.type === SYNC_EVENT) { + throw new Error('dispatch failed'); + } + return true; + }); + + const sig = makeSignal(() => createLocalStorageStringSignal('err-key', 'def')); + await flush(); + // setItem runs before the (swallowed) broadcast error + expect(localStorage.getItem('err-key')).toBe('def'); + + sig.setValue('next'); + await flush(); + expect(localStorage.getItem('err-key')).toBe('next'); + + dispatchSpy.mockRestore(); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/logger.test.ts b/frontend-modern/src/utils/__tests__/logger.test.ts new file mode 100644 index 0000000000..c13aa0d2f5 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/logger.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +type LoggerModule = typeof import('@/utils/logger'); + +describe('logger', () => { + let logSpy: ReturnType; + let warnSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('in development (DEV=true)', () => { + let logger: LoggerModule['logger']; + let logError: LoggerModule['logError']; + + beforeEach(async () => { + vi.resetModules(); + vi.stubEnv('DEV', true); + const mod = await import('@/utils/logger'); + logger = mod.logger; + logError = mod.logError; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('debug logs with a [DEBUG] prefix via console.log', () => { + logger.debug('booting up', { phase: 1 }); + + expect(logSpy).toHaveBeenCalledWith('%s', '[DEBUG] booting up', { phase: 1 }); + }); + + it('debug omits data gracefully (defaults to empty string)', () => { + logger.debug('no data'); + + expect(logSpy).toHaveBeenCalledWith('%s', '[DEBUG] no data', ''); + }); + + const debugDataCases: Array<[string, unknown, unknown]> = [ + ['object', { a: 1 }, { a: 1 }], + ['array', [1, 2], [1, 2]], + ['number 0', 0, 0], + ['false boolean', false, false], + ['empty string', '', ''], + ['null becomes empty string', null, ''], + ['undefined becomes empty string', undefined, ''], + ['NaN', NaN, NaN], + ]; + + it.each(debugDataCases)( + 'debug passes %s through as the third console.log arg', + (_label, data, expected) => { + logger.debug('msg', data); + + expect(logSpy).toHaveBeenCalledWith('%s', '[DEBUG] msg', expected); + }, + ); + + it('info always logs in dev even without keywords', () => { + logger.info('just info'); + + expect(logSpy).toHaveBeenCalledWith('%s', '[INFO] just info', ''); + }); + + it('info passes data through in dev', () => { + logger.info('booting', { phase: 1 }); + + expect(logSpy).toHaveBeenCalledWith('%s', '[INFO] booting', { phase: 1 }); + }); + + it('warn always logs via console.warn with [WARN] prefix', () => { + logger.warn('careful', { x: 1 }); + + expect(warnSpy).toHaveBeenCalledWith('%s', '[WARN] careful', { x: 1 }); + }); + + it('warn defaults data to empty string when omitted', () => { + logger.warn('careful'); + + expect(warnSpy).toHaveBeenCalledWith('%s', '[WARN] careful', ''); + }); + + it('error always logs via console.error with [ERROR] prefix', () => { + const err = new Error('boom'); + logger.error('something broke', err); + + expect(errorSpy).toHaveBeenCalledWith('%s', '[ERROR] something broke', err); + }); + + it('error defaults error to empty string when omitted', () => { + logger.error('something broke'); + + expect(errorSpy).toHaveBeenCalledWith('%s', '[ERROR] something broke', ''); + }); + + it('logError is an alias for logger.error', () => { + expect(logError).toBe(logger.error); + + const err = new Error('x'); + logError('aliased', err); + + expect(errorSpy).toHaveBeenCalledWith('%s', '[ERROR] aliased', err); + }); + }); + + describe('in production (DEV=false)', () => { + let logger: LoggerModule['logger']; + + beforeEach(async () => { + vi.resetModules(); + vi.stubEnv('DEV', false); + const mod = await import('@/utils/logger'); + logger = mod.logger; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('debug does not log in production', () => { + logger.debug('hello', { a: 1 }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + const infoKeywordCases: Array<[string, boolean]> = [ + ['connection established', true], + ['the operation failed', true], + ['an error occurred', true], + ['established', true], + ['failed', true], + ['error', true], + ['all systems nominal', false], + ['just info', false], + ]; + + it.each(infoKeywordCases)( + 'info in production logs for %j? %s', + (message, shouldLog) => { + logger.info(message); + + if (shouldLog) { + expect(logSpy).toHaveBeenCalledWith('%s', `[INFO] ${message}`, ''); + } else { + expect(logSpy).not.toHaveBeenCalled(); + } + }, + ); + + // The keyword check uses String.includes, which is case-sensitive. Capitalized + // variants of the trigger words are therefore NOT surfaced in production. + const capitalizedKeywordCases: Array<[string]> = [ + ['Established'], + ['Failed'], + ['Error'], + ]; + + it.each(capitalizedKeywordCases)( + 'info in production does NOT log for capitalized keyword %j (case-sensitive match, suspected bug)', + (message) => { + logger.info(message); + + expect(logSpy).not.toHaveBeenCalled(); + }, + ); + + it('info in production passes data through when a keyword matches', () => { + logger.info('connection established', { id: 7 }); + + expect(logSpy).toHaveBeenCalledWith('%s', '[INFO] connection established', { id: 7 }); + }); + + it('warn still logs in production', () => { + logger.warn('careful'); + + expect(warnSpy).toHaveBeenCalledWith('%s', '[WARN] careful', ''); + }); + + it('error still logs in production', () => { + const err = new Error('boom'); + logger.error('broke', err); + + expect(errorSpy).toHaveBeenCalledWith('%s', '[ERROR] broke', err); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/toast.test.ts b/frontend-modern/src/utils/__tests__/toast.test.ts new file mode 100644 index 0000000000..341454824b --- /dev/null +++ b/frontend-modern/src/utils/__tests__/toast.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('@/utils/logger', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { logger } from '@/utils/logger'; +import { + showToast, + showSuccess, + showError, + showErrorWithDetail, + showWarning, +} from '@/utils/toast'; + +describe('toast', () => { + const loggerInfo = vi.mocked(logger.info); + + beforeEach(() => { + loggerInfo.mockReset(); + delete (window as unknown as { showToast?: unknown }).showToast; + }); + + afterEach(() => { + delete (window as unknown as { showToast?: unknown }).showToast; + }); + + describe('showToast', () => { + it('delegates to window.showToast and returns its id when available', () => { + const mock = vi.fn(() => 'toast-id-1'); + (window as unknown as { showToast: typeof mock }).showToast = mock; + + const result = showToast('success', 'Saved', 'all good', 5000, 'details'); + + expect(mock).toHaveBeenCalledWith('success', 'Saved', 'all good', 5000, 'details'); + expect(result).toBe('toast-id-1'); + }); + + it('passes undefined for omitted args when delegating', () => { + const mock = vi.fn(() => 'id'); + (window as unknown as { showToast: typeof mock }).showToast = mock; + + const result = showToast('info', 'Hello'); + + expect(mock).toHaveBeenCalledWith('info', 'Hello', undefined, undefined, undefined); + expect(result).toBe('id'); + }); + + it('returns undefined when window.showToast returns undefined', () => { + const mock = vi.fn(() => undefined); + (window as unknown as { showToast: typeof mock }).showToast = mock; + + const result = showToast('info', 'Hello'); + + expect(result).toBeUndefined(); + }); + + it('delegates with an empty title', () => { + const mock = vi.fn(() => 'id'); + (window as unknown as { showToast: typeof mock }).showToast = mock; + + showToast('info', ''); + + expect(mock).toHaveBeenCalledWith('info', '', undefined, undefined, undefined); + }); + + it('falls back to logger.info and returns undefined when window.showToast is missing', () => { + const result = showToast('error', 'Failed', 'something broke'); + + expect(loggerInfo).toHaveBeenCalledWith('[toast:error] Failed: something broke'); + expect(result).toBeUndefined(); + }); + + it('omits the message suffix when no message is provided (fallback)', () => { + showToast('warning', 'Careful'); + + expect(loggerInfo).toHaveBeenCalledWith('[toast:warning] Careful'); + }); + + it('omits the message suffix when message is an empty string (fallback)', () => { + showToast('success', 'Done', ''); + + expect(loggerInfo).toHaveBeenCalledWith('[toast:success] Done'); + }); + + it('includes the toast type in the fallback log line', () => { + showToast('info', 'Hi', 'msg'); + + expect(loggerInfo).toHaveBeenCalledWith('[toast:info] Hi: msg'); + }); + + it('falls back with an empty title (fallback)', () => { + showToast('info', ''); + + expect(loggerInfo).toHaveBeenCalledWith('[toast:info] '); + }); + }); + + describe('convenience wrappers', () => { + type ToastWrapper = (title: string, message?: string, duration?: number) => string | undefined; + const wrapperCases: Array<{ name: string; fn: ToastWrapper; type: string }> = [ + { name: 'showSuccess', fn: showSuccess, type: 'success' }, + { name: 'showError', fn: showError, type: 'error' }, + { name: 'showWarning', fn: showWarning, type: 'warning' }, + ]; + + it.each(wrapperCases)( + '$name delegates to showToast with type "$type"', + ({ fn, type }) => { + const mock = vi.fn(() => 'id'); + (window as unknown as { showToast: typeof mock }).showToast = mock; + + const result = fn('Title', 'Message', 3000); + + expect(mock).toHaveBeenCalledWith(type, 'Title', 'Message', 3000, undefined); + expect(result).toBe('id'); + }, + ); + + it('showErrorWithDetail delegates with message undefined and detail as the 5th arg', () => { + const mock = vi.fn(() => 'id'); + (window as unknown as { showToast: typeof mock }).showToast = mock; + + const result = showErrorWithDetail('Boom', 'stack trace', 4000); + + expect(mock).toHaveBeenCalledWith('error', 'Boom', undefined, 4000, 'stack trace'); + expect(result).toBe('id'); + }); + + it('showErrorWithDetail works without duration or detail', () => { + const mock = vi.fn(() => 'id'); + (window as unknown as { showToast: typeof mock }).showToast = mock; + + showErrorWithDetail('Boom'); + + expect(mock).toHaveBeenCalledWith('error', 'Boom', undefined, undefined, undefined); + }); + + it('passes duration=0 through to window.showToast', () => { + const mock = vi.fn(() => 'id'); + (window as unknown as { showToast: typeof mock }).showToast = mock; + + showSuccess('Title', 'Message', 0); + + expect(mock).toHaveBeenCalledWith('success', 'Title', 'Message', 0, undefined); + }); + + it('convenience wrappers fall back to logger when the toast system is not ready', () => { + showSuccess('Saved', 'done'); + showError('Failed', 'broken'); + showWarning('Careful', 'careful'); + showErrorWithDetail('Boom', 'trace'); + + expect(loggerInfo).toHaveBeenCalledWith('[toast:success] Saved: done'); + expect(loggerInfo).toHaveBeenCalledWith('[toast:error] Failed: broken'); + expect(loggerInfo).toHaveBeenCalledWith('[toast:warning] Careful: careful'); + expect(loggerInfo).toHaveBeenCalledWith('[toast:error] Boom'); + }); + }); +});