Add unit tests for localStorage, logger, toast utils

This commit is contained in:
rcourtman 2026-07-09 21:47:27 +01:00
parent 9706c4fade
commit b26716b3c9
3 changed files with 849 additions and 0 deletions

View file

@ -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> = [() => T, (v: T) => void];
const disposables: Array<() => void> = [];
function makeSignal<T>(factory: () => AnySignal<T>): {
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<T>;
});
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();
});
});
});

View file

@ -0,0 +1,197 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
type LoggerModule = typeof import('@/utils/logger');
describe('logger', () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let warnSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
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);
});
});
});

View file

@ -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');
});
});
});