From 25870b4e552af54dc867b973a9fcff4ee4abc707 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 15 May 2026 21:12:25 +0200 Subject: [PATCH] Expand Soma component coverage --- .../popover/popover-provider.svelte.test.ts | 121 +++++++++++++++ .../range-calendar-provider.svelte.test.ts | 143 ++++++++++++++++++ .../select/select-provider.svelte.test.ts | 133 ++++++++++++++++ .../components/toast/toaster.svelte.test.ts | 66 ++++++++ 4 files changed, 463 insertions(+) create mode 100644 src/uix/soma/components/popover/popover-provider.svelte.test.ts create mode 100644 src/uix/soma/components/range-calendar/range-calendar-provider.svelte.test.ts create mode 100644 src/uix/soma/components/select/select-provider.svelte.test.ts create mode 100644 src/uix/soma/components/toast/toaster.svelte.test.ts diff --git a/src/uix/soma/components/popover/popover-provider.svelte.test.ts b/src/uix/soma/components/popover/popover-provider.svelte.test.ts new file mode 100644 index 000000000..c3815ac0b --- /dev/null +++ b/src/uix/soma/components/popover/popover-provider.svelte.test.ts @@ -0,0 +1,121 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createActiveDom } from '$adom'; +import { createActiveTimers } from '$timer'; +import { state } from '$libs/reactive'; +import type { Morfo } from '$uix/morfo'; +import { Soma } from '$soma/core/soma.svelte'; +import { + createSomaRuntime, + type EventEngineEmitter, + type SomaRuntimeSources +} from '$soma/runtime.svelte'; +import { FloatingProvider } from '$soma/layers/floating'; + +import { PopoverProvider } from './popover-provider.svelte'; + +function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { + let result!: T; + const cleanup = $effect.root(() => { + result = fn(); + }); + return { result, cleanup }; +} + +function installSomaHarness() { + const dom = createActiveDom(); + const timers = createActiveTimers(); + const eventEngine = { emit: vi.fn(async () => {}) } satisfies EventEngineEmitter; + const soma = { + dom, + events: eventEngine, + uix: { timers }, + runtime: (morfo: Morfo, sources: Omit) => + createSomaRuntime(morfo, { + dom, + eventEngine, + translate: (key) => key, + ...sources + }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(PopoverProvider.ctx, 'set').mockImplementation((value) => value); + vi.spyOn(FloatingProvider, 'create').mockReturnValue({} as FloatingProvider); + + return { dom, eventEngine, timers }; +} + +function createPopoverOpts() { + return { + id: state('popover-root'), + ref: state(null), + open: state(false), + modal: state(false), + onOpenChangeComplete: state(() => {}) + }; +} + +describe('PopoverProvider', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('toggles open state directly for imperative callers', () => { + const { dom, timers } = installSomaHarness(); + const opts = createPopoverOpts(); + + const { result: provider, cleanup } = withEffectRoot(() => PopoverProvider.create(opts)); + + provider.toggle(); + expect(opts.open.current).toBe(true); + provider.toggle(); + expect(opts.open.current).toBe(false); + + cleanup(); + timers.dispose(); + dom.dispose(); + }); + + it('drives hover open and close through timers and the morfo runtime', async () => { + vi.useFakeTimers(); + const { dom, eventEngine, timers } = installSomaHarness(); + const opts = createPopoverOpts(); + const target = document.createElement('button'); + document.body.appendChild(target); + + const { result: provider, cleanup } = withEffectRoot(() => { + const p = PopoverProvider.create(opts); + p.contentId.current = 'popover-content'; + p.runtime.part('content', { + id: state('popover-content'), + ref: state(target), + syncAttrs: true + }); + return p; + }); + provider.setTriggerRef(target); + + provider.scheduleHoverOpen(25, target); + await vi.advanceTimersByTimeAsync(25); + + expect(opts.open.current).toBe(true); + expect(eventEngine.emit).toHaveBeenCalledWith( + expect.objectContaining({ name: 'present', family: 'emerge', target }) + ); + + provider.scheduleHoverClose(25); + await vi.advanceTimersByTimeAsync(25); + + expect(opts.open.current).toBe(false); + expect(target.getAttribute('data-last-action')).toBe('dismissed'); + + cleanup(); + timers.dispose(); + dom.dispose(); + }); +}); diff --git a/src/uix/soma/components/range-calendar/range-calendar-provider.svelte.test.ts b/src/uix/soma/components/range-calendar/range-calendar-provider.svelte.test.ts new file mode 100644 index 000000000..f18bd316d --- /dev/null +++ b/src/uix/soma/components/range-calendar/range-calendar-provider.svelte.test.ts @@ -0,0 +1,143 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createActiveDom } from '$adom'; +import { state } from '$libs/reactive'; +import type { Morfo } from '$uix/morfo'; +import { CalendarDate, type DateMatcher, type DateRange } from '$libs/days'; +import { Soma } from '$soma/core/soma.svelte'; +import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte'; + +import { + RangeCalendarProvider, + resolveInitialRangeCalendarPlaceholder +} from './range-calendar-provider.svelte'; + +function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { + let result!: T; + const cleanup = $effect.root(() => { + result = fn(); + }); + return { result, cleanup }; +} + +function installSomaHarness() { + const dom = createActiveDom(); + const soma = { + dom, + langs: { + getLocale: () => 'en-US', + ts: (key: string) => key + }, + dates: { + getDateOrder: () => 'MDY' + }, + runtime: (morfo: Morfo, sources: Omit) => + createSomaRuntime(morfo, { + dom, + translate: (key) => key, + ...sources + }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(RangeCalendarProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom }; +} + +function createRangeCalendarOpts() { + return { + id: state('range-calendar-root'), + ref: state(document.createElement('div')), + placeholder: state(new CalendarDate(2026, 5, 1)), + value: state({ start: undefined, end: undefined }), + minValue: state(undefined), + maxValue: state(undefined), + minDays: state(undefined), + maxDays: state(undefined), + allowSingleDay: state(false), + disabled: state(false), + pagedNavigation: state(false), + weekStartsOn: state<0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined>(undefined), + weekdayFormat: state('short'), + isDateDisabled: state(undefined), + isDateUnavailable: state(undefined), + isDateHoliday: state(undefined), + fixedWeeks: state(false), + numberOfMonths: state(1), + calendarLabel: state(undefined), + readonly: state(false), + disableDaysOutsideMonth: state(false), + monthFormat: state string)>('long'), + yearFormat: state string)>('numeric'), + showWeekNumbers: state(false), + weekNumberFormat: state<'iso' | 'simple'>('iso'), + announceNavigation: state(false), + initialFocus: state(false), + dir: state<'ltr' | 'rtl'>('ltr'), + locale: state('en-US') + }; +} + +describe('RangeCalendarProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('resolves initial placeholder from range start before falling back to today', () => { + const start = new CalendarDate(2026, 5, 15); + + expect(resolveInitialRangeCalendarPlaceholder(undefined, { start, end: undefined })).toBe(start); + }); + + it('orders clicked endpoints and clears anchor when the range closes', () => { + const { dom } = installSomaHarness(); + const opts = createRangeCalendarOpts(); + const late = new CalendarDate(2026, 5, 20); + const early = new CalendarDate(2026, 5, 10); + + const { result: provider, cleanup } = withEffectRoot(() => + RangeCalendarProvider.create(opts) + ); + + provider.select(late); + expect(opts.value.current).toEqual({ start: late, end: undefined }); + expect(provider.anchor).toBe(late); + + provider.select(early); + expect(opts.value.current).toEqual({ start: early, end: late }); + expect(provider.anchor).toBeUndefined(); + + cleanup(); + dom.dispose(); + }); + + it('clamps ranges that exceed maxDays and ignores too-short closes', () => { + const { dom } = installSomaHarness(); + const opts = createRangeCalendarOpts(); + opts.maxDays.current = 3; + const start = new CalendarDate(2026, 5, 10); + const far = new CalendarDate(2026, 5, 20); + + const { result: provider, cleanup } = withEffectRoot(() => + RangeCalendarProvider.create(opts) + ); + + provider.select(start); + provider.select(far); + expect(opts.value.current).toEqual({ start, end: new CalendarDate(2026, 5, 12) }); + + opts.value.current = { start: undefined, end: undefined }; + opts.minDays.current = 3; + provider.select(start); + provider.select(new CalendarDate(2026, 5, 11)); + expect(opts.value.current).toEqual({ start, end: undefined }); + expect(provider.anchor).toBe(start); + + cleanup(); + dom.dispose(); + }); +}); diff --git a/src/uix/soma/components/select/select-provider.svelte.test.ts b/src/uix/soma/components/select/select-provider.svelte.test.ts new file mode 100644 index 000000000..87a7f447e --- /dev/null +++ b/src/uix/soma/components/select/select-provider.svelte.test.ts @@ -0,0 +1,133 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createActiveDom } from '$adom'; +import { createActiveTimers } from '$timer'; +import { state } from '$libs/reactive'; +import type { Morfo } from '$uix/morfo'; +import { Soma } from '$soma/core/soma.svelte'; +import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte'; +import { FloatingProvider } from '$soma/layers/floating'; + +import { SelectProvider } from './select-provider.svelte'; +import type { ScrollAlignment, SelectType } from './select-provider.svelte'; + +function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { + let result!: T; + const cleanup = $effect.root(() => { + result = fn(); + }); + return { result, cleanup }; +} + +function installSomaHarness() { + const dom = createActiveDom(); + const timers = createActiveTimers(); + const soma = { + dom, + uix: { timers }, + runtime: (morfo: Morfo, sources: Omit) => + createSomaRuntime(morfo, { + dom, + translate: (key) => key, + ...sources + }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(SelectProvider.ctx, 'set').mockImplementation((value) => value); + vi.spyOn(FloatingProvider, 'create').mockReturnValue({} as FloatingProvider); + + return { dom, timers }; +} + +function createSelectOpts() { + return { + id: state('select-root'), + ref: state(null), + open: state(true), + value: state([]), + type: state('single'), + disabled: state(false), + required: state(false), + name: state('choice'), + loop: state(false), + allowDeselect: state(false), + scrollAlignment: state('nearest'), + dir: state<'ltr' | 'rtl'>('ltr'), + onOpenChangeComplete: state(() => {}) + }; +} + +describe('SelectProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('selects, closes and resolves labels in single-select mode', () => { + const { dom, timers } = installSomaHarness(); + const opts = createSelectOpts(); + + const { result: provider, cleanup } = withEffectRoot(() => SelectProvider.create(opts)); + provider.registerLabel('a', 'Alpha'); + + provider.selectItem('a'); + + expect(opts.value.current).toEqual(['a']); + expect(opts.open.current).toBe(false); + expect(provider.getDisplayText()).toBe('Alpha'); + + opts.open.current = true; + opts.allowDeselect.current = true; + provider.selectItem('a'); + + expect(opts.value.current).toEqual([]); + expect(opts.open.current).toBe(false); + + cleanup(); + timers.dispose(); + dom.dispose(); + }); + + it('toggles values without closing in multiple-select mode', () => { + const { dom, timers } = installSomaHarness(); + const opts = createSelectOpts(); + opts.type.current = 'multiple'; + opts.value.current = ['a']; + + const { result: provider, cleanup } = withEffectRoot(() => SelectProvider.create(opts)); + + provider.selectItem('b'); + expect(opts.value.current).toEqual(['a', 'b']); + expect(opts.open.current).toBe(true); + + provider.selectItem('a'); + expect(opts.value.current).toEqual(['b']); + expect(opts.open.current).toBe(true); + + cleanup(); + timers.dispose(); + dom.dispose(); + }); + + it('filters disabled DOM items from navigation candidates', () => { + const { dom, timers } = installSomaHarness(); + const opts = createSelectOpts(); + const container = document.createElement('div'); + container.innerHTML = ` +
+
+
+ `; + + const { result: provider, cleanup } = withEffectRoot(() => SelectProvider.create(opts)); + + expect(provider.getItems(container).map((el) => el.id)).toEqual(['a', 'c']); + + cleanup(); + timers.dispose(); + dom.dispose(); + }); +}); diff --git a/src/uix/soma/components/toast/toaster.svelte.test.ts b/src/uix/soma/components/toast/toaster.svelte.test.ts new file mode 100644 index 000000000..4eb9ca2b7 --- /dev/null +++ b/src/uix/soma/components/toast/toaster.svelte.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from 'vitest'; + +import { Toaster } from './toaster.svelte'; + +describe('Toaster', () => { + it('routes max overflow through dismiss state instead of removing immediately', () => { + const toaster = new Toaster({ max: 2, duration: 0 }); + + const first = toaster.create({ title: 'One' }); + const second = toaster.create({ title: 'Two' }); + const third = toaster.create({ title: 'Three' }); + + expect(toaster.toasts.map((toast) => toast.id)).toEqual([first, second, third]); + expect(toaster.toasts.find((toast) => toast.id === first)?.dismissing).toBe(true); + expect(toaster.toasts.find((toast) => toast.id === second)?.dismissing).toBeFalsy(); + }); + + it('dismisses all toasts and calls onDismiss only when removed', () => { + const onDismiss = vi.fn(); + const toaster = new Toaster({ duration: 0 }); + const id = toaster.create({ title: 'Saved', onDismiss }); + toaster.create({ title: 'Queued' }); + + toaster.dismiss(); + + expect(toaster.toasts.every((toast) => toast.dismissing)).toBe(true); + expect(onDismiss).not.toHaveBeenCalled(); + + toaster.remove(id); + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(toaster.toasts.some((toast) => toast.id === id)).toBe(false); + }); + + it('updates promise toasts on fulfill and threat paths', async () => { + const toaster = new Toaster({ duration: 0 }); + + const fulfillId = toaster.promise(Promise.resolve('ok'), { + loading: { title: 'Loading' }, + fulfill: (value) => ({ title: value }), + threat: { title: 'Failed' } + }); + await Promise.resolve(); + + expect(toaster.toasts.find((toast) => toast.id === fulfillId)).toMatchObject({ + title: 'ok', + intent: 'fulfill', + loading: false + }); + + const threatId = toaster.promise(Promise.reject(new Error('nope')), { + loading: { title: 'Loading' }, + fulfill: { title: 'Done' }, + threat: (err) => ({ title: err instanceof Error ? err.message : 'Failed' }) + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(toaster.toasts.find((toast) => toast.id === threatId)).toMatchObject({ + title: 'nope', + intent: 'threat', + loading: false + }); + }); +});