diff --git a/src/uix/soma/components/calendar/calendar-provider.svelte.test.ts b/src/uix/soma/components/calendar/calendar-provider.svelte.test.ts new file mode 100644 index 000000000..88f0d55d6 --- /dev/null +++ b/src/uix/soma/components/calendar/calendar-provider.svelte.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest'; + +import { CalendarDate, CalendarDateTime } from '$libs/days'; + +import { resolveInitialCalendarPlaceholder } from './calendar-provider.svelte'; +import { + applyMultipleSelection, + applySingleSelection, + computeDateFlags, + createMonths +} from './helpers'; + +describe('CalendarProvider helpers', () => { + it('resolves the initial placeholder from selection before falling back to today', () => { + const selected = new CalendarDate(2026, 5, 15); + + expect(resolveInitialCalendarPlaceholder(undefined, selected, 'single')).toBe(selected); + expect(resolveInitialCalendarPlaceholder(undefined, [selected], 'multiple')).toBe(selected); + }); + + it('generates fixed six-week month grids', () => { + const months = createMonths(new CalendarDate(2026, 2, 1), { + numberOfMonths: 2, + weekStartsOn: 1, + fixedWeeks: true, + locale: 'es-ES' + }); + + expect(months).toHaveLength(2); + expect(months[0].dates).toHaveLength(42); + expect(months[0].weeks).toHaveLength(6); + expect(months[1].dates).toHaveLength(42); + }); + + it('preserves time when single selection changes the calendar date', () => { + const previous = new CalendarDateTime(2026, 5, 15, 13, 45, 30); + const next = applySingleSelection(previous, new CalendarDate(2026, 6, 1), false); + + expect(next).toBeInstanceOf(CalendarDateTime); + expect((next as CalendarDateTime).hour).toBe(13); + expect((next as CalendarDateTime).minute).toBe(45); + }); + + it('toggles multiple selection and resets when maxDays is exceeded', () => { + const first = new CalendarDate(2026, 5, 1); + const second = new CalendarDate(2026, 5, 2); + const third = new CalendarDate(2026, 5, 3); + + expect(applyMultipleSelection([first], first, false, undefined)).toEqual([]); + expect(applyMultipleSelection([first, second], third, false, 2)).toEqual([third]); + }); + + it('composes disabled, unavailable and holiday flags independently', () => { + const day = new CalendarDate(2026, 5, 15); + const flags = computeDateFlags(day, { + minValue: new CalendarDate(2026, 5, 1), + maxValue: new CalendarDate(2026, 5, 31), + disabled: false, + isDateDisabled: undefined, + isDateUnavailable: (date) => date.day === 15, + isDateHoliday: (date) => date.day === 15 + }); + + expect(flags).toEqual({ disabled: false, unavailable: true, holiday: true }); + }); +}); diff --git a/src/uix/soma/components/command/command-provider.svelte.test.ts b/src/uix/soma/components/command/command-provider.svelte.test.ts new file mode 100644 index 000000000..d0d1ce72a --- /dev/null +++ b/src/uix/soma/components/command/command-provider.svelte.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { flushSync } from 'svelte'; + +import { createActiveDom } from '$adom'; +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 { CommandProvider } from './command-provider.svelte'; +import { computeCommandScore } from './score'; + +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, + runtime: (morfo: Morfo, sources: Omit) => + createSomaRuntime(morfo, { + dom, + translate: (key) => key, + ...sources + }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(CommandProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom }; +} + +function createCommandOpts() { + return { + id: state('command-root'), + ref: state(null), + value: state(''), + search: state(''), + label: state('Commands'), + shouldFilter: state(true), + filter: state(computeCommandScore), + loop: state(false), + vimBindings: state(false), + columns: state(1), + disablePointerSelection: state(false), + dir: state<'ltr' | 'rtl'>('ltr') + }; +} + +function item(value: string, patch: Partial[0]> = {}) { + return { + value, + keywords: [], + forceMount: false, + disabled: false, + groupValue: undefined, + element: null, + score: 1, + ...patch + } as Parameters[0]; +} + +describe('CommandProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('filters visible items and auto-selects the first surviving value', () => { + const { dom } = installSomaHarness(); + const opts = createCommandOpts(); + opts.search.current = 'op'; + + const { result: provider, cleanup } = withEffectRoot(() => CommandProvider.create(opts)); + provider.registerItem(item('Open')); + provider.registerItem(item('Save')); + provider.registerItem(item('Hidden', { forceMount: true })); + flushSync(); + + expect(provider.visibleOrderedValues).toEqual(['Open', 'Hidden']); + expect(provider.isVisible('Save')).toBe(false); + expect(opts.value.current).toBe('Open'); + + cleanup(); + dom.dispose(); + }); + + it('navigates through visible values and honours disabled selection handlers', () => { + const { dom } = installSomaHarness(); + const opts = createCommandOpts(); + opts.loop.current = true; + const onSelect = vi.fn(); + + const { result: provider, cleanup } = withEffectRoot(() => CommandProvider.create(opts)); + provider.registerItem(item('Open')); + provider.registerItem(item('Close', { disabled: true })); + provider.registerOnSelect('Open', onSelect); + provider.registerOnSelect('Close', onSelect); + flushSync(); + + provider.last(); + expect(opts.value.current).toBe('Close'); + provider.selectCurrent(); + expect(onSelect).not.toHaveBeenCalled(); + + provider.next(); + expect(opts.value.current).toBe('Open'); + provider.selectCurrent(); + expect(onSelect).toHaveBeenCalledWith('Open'); + + cleanup(); + dom.dispose(); + }); +}); diff --git a/src/uix/soma/components/date-field/date-field-provider.svelte.test.ts b/src/uix/soma/components/date-field/date-field-provider.svelte.test.ts new file mode 100644 index 000000000..17cd346ea --- /dev/null +++ b/src/uix/soma/components/date-field/date-field-provider.svelte.test.ts @@ -0,0 +1,81 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from 'vitest'; + +import { createActiveDom } from '$adom'; +import { CalendarDate } from '$libs/days'; +import { + areAllSegmentsFilledDOM, + getFirstSegment, + getValueFromSegmentsDOM, + handleSegmentNavigation, + initSegmentStates, + isAcceptableSegmentKey +} from '$soma/datetime'; + +function createField() { + const field = document.createElement('div'); + for (const [id, part, text] of [ + ['month', 'month', '05'], + ['literal', 'literal', '/'], + ['day', 'day', '15'], + ['year', 'year', '2026'] + ] as const) { + const segment = document.createElement('span'); + segment.id = id; + segment.dataset.segment = part; + segment.textContent = text; + field.appendChild(segment); + } + document.body.appendChild(field); + return field; +} + +describe('DateField segment helpers', () => { + it('creates fresh segment state maps for each field instance', () => { + const first = initSegmentStates(); + const second = initSegmentStates(); + + first.day.updating = '1'; + + expect(second.day.updating).toBeNull(); + expect(second.month.hasLeftFocus).toBe(true); + }); + + it('accepts numeric input and navigation keys only', () => { + expect(isAcceptableSegmentKey('7')).toBe(true); + expect(isAcceptableSegmentKey('ArrowRight')).toBe(true); + expect(isAcceptableSegmentKey('x')).toBe(false); + }); + + it('reads only rendered editable segments from the DOM', () => { + const field = createField(); + + expect(getFirstSegment(field)?.id).toBe('month'); + expect(areAllSegmentsFilledDOM({ month: '05', day: '15', year: '2026' }, field)).toBe(true); + + const value = getValueFromSegmentsDOM({ + segmentObj: { month: '05', day: '15', year: '2026' }, + fieldNode: field, + dateRef: new CalendarDate(2026, 1, 1) + }); + + expect(value.month).toBe(5); + expect(value.day).toBe(15); + expect(value.year).toBe(2026); + }); + + it('moves focus across segments through ActiveDom', () => { + const dom = createActiveDom(); + const field = createField(); + const month = field.querySelector('#month')!; + const focus = vi.spyOn(dom, 'focus'); + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }); + Object.defineProperty(event, 'currentTarget', { value: month }); + + handleSegmentNavigation(dom, event, field); + + expect(focus).toHaveBeenCalledWith(field.querySelector('#day')); + dom.dispose(); + }); +}); diff --git a/src/uix/soma/components/dialog/dialog-provider.svelte.test.ts b/src/uix/soma/components/dialog/dialog-provider.svelte.test.ts new file mode 100644 index 000000000..070a8eaa4 --- /dev/null +++ b/src/uix/soma/components/dialog/dialog-provider.svelte.test.ts @@ -0,0 +1,121 @@ +// @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 { Soma } from '$soma/core/soma.svelte'; +import { + createSomaRuntime, + type EventEngineEmitter, + type SomaRuntimeSources +} from '$soma/runtime.svelte'; + +import { DialogProvider } from './dialog-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 eventEngine = { emit: vi.fn(async () => {}) } satisfies EventEngineEmitter; + const soma = { + dom, + events: eventEngine, + 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(DialogProvider.ctx, 'getOr').mockReturnValue(undefined); + vi.spyOn(DialogProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom, eventEngine }; +} + +function createDialogOpts() { + return { + id: state('dialog-root'), + ref: state(null), + open: state(false), + disabled: state(false), + modal: state(true), + variant: state<'dialog' | 'alertdialog'>('dialog'), + intent: state<'neutral' | 'risk' | 'threat'>('neutral'), + onOpenChangeComplete: state(() => {}) + }; +} + +describe('DialogProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('opens and closes through the morfo runtime without a registered content part', async () => { + const { dom, eventEngine } = installSomaHarness(); + const opts = createDialogOpts(); + const fallbackTarget = document.createElement('div'); + document.body.appendChild(fallbackTarget); + + const { result: provider, cleanup } = withEffectRoot(() => { + const p = DialogProvider.create(opts); + p.contentId.current = 'dialog-content'; + p.runtime.part('content', { + id: state('dialog-content'), + ref: state(fallbackTarget), + syncAttrs: true + }); + return p; + }); + + provider.handleOpen(); + expect(opts.open.current).toBe(true); + + await provider.triggerClose('close-dismiss', fallbackTarget); + + expect(opts.open.current).toBe(false); + expect(fallbackTarget.getAttribute('data-last-action')).toBe('dismissed'); + expect(fallbackTarget.getAttribute('data-state')).toBe('closed'); + expect(eventEngine.emit).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'close-dismiss', + family: 'emerge', + target: fallbackTarget + }) + ); + + cleanup(); + dom.dispose(); + }); + + it('does not mutate open state while disabled', () => { + const { dom } = installSomaHarness(); + const opts = createDialogOpts(); + opts.disabled.current = true; + opts.open.current = false; + + const { result: provider, cleanup } = withEffectRoot(() => DialogProvider.create(opts)); + + provider.handleOpen(); + expect(opts.open.current).toBe(false); + + opts.open.current = true; + provider.handleClose(); + expect(opts.open.current).toBe(true); + + cleanup(); + dom.dispose(); + }); +}); diff --git a/src/uix/soma/components/drawer/drawer-provider.svelte.test.ts b/src/uix/soma/components/drawer/drawer-provider.svelte.test.ts new file mode 100644 index 000000000..7021067f2 --- /dev/null +++ b/src/uix/soma/components/drawer/drawer-provider.svelte.test.ts @@ -0,0 +1,151 @@ +// @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 { Soma } from '$soma/core/soma.svelte'; +import { + createSomaRuntime, + type EventEngineEmitter, + type SomaRuntimeSources +} from '$soma/runtime.svelte'; + +import { DrawerProvider } from './drawer-provider.svelte'; +import type { DrawerDirection, DrawerSnapPoint, DrawerVariant } from './types'; + +function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { + let result!: T; + const cleanup = $effect.root(() => { + result = fn(); + }); + return { result, cleanup }; +} + +function installSomaHarness(dir: 'ltr' | 'rtl' = 'ltr') { + const dom = createActiveDom(); + const eventEngine = { emit: vi.fn(async () => {}) } satisfies EventEngineEmitter; + const soma = { + dom, + events: eventEngine, + prefs: { getDir: () => dir }, + 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(DrawerProvider.ctx, 'getOr').mockReturnValue(undefined); + vi.spyOn(DrawerProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom, eventEngine }; +} + +function createDrawerOpts() { + return { + id: state('drawer-root'), + ref: state(null), + open: state(false), + activeSnapPoint: state(null), + isDragging: state(false), + disabled: state(false), + variant: state('overlay'), + dismissible: state(true), + handleOnly: state(false), + dragToDismiss: state(false), + direction: state('bottom'), + snapPoints: state(undefined), + snapToSequential: state(false), + closeThreshold: state(0.25), + velocityThreshold: state(0.5), + scrollLockTimeout: state(500), + minSize: state(undefined), + maxSize: state(undefined), + onOpenChangeComplete: state(() => {}), + onDragStart: state<((event: PointerEvent) => void) | undefined>(undefined), + onDrag: state<((event: PointerEvent, progress: number) => void) | undefined>(undefined), + onRelease: state<((event: PointerEvent, willClose: boolean) => void) | undefined>(undefined), + onActiveSnapPointChange: state(() => {}) + }; +} + +describe('DrawerProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('resolves logical directions from the active UI direction preference', () => { + const { dom } = installSomaHarness('rtl'); + const opts = createDrawerOpts(); + opts.direction.current = 'start'; + + const { result: provider, cleanup } = withEffectRoot(() => DrawerProvider.create(opts)); + + expect(provider.resolvedSide).toBe('right'); + + cleanup(); + dom.dispose(); + }); + + it('dismisses through the morfo runtime and clears transient snap state', async () => { + const { dom, eventEngine } = installSomaHarness(); + const opts = createDrawerOpts(); + opts.open.current = true; + opts.activeSnapPoint.current = 0.5; + const fallbackTarget = document.createElement('div'); + document.body.appendChild(fallbackTarget); + + const { result: provider, cleanup } = withEffectRoot(() => { + const p = DrawerProvider.create(opts); + p.contentId.current = 'drawer-content'; + p.runtime.part('content', { + id: state('drawer-content'), + ref: state(fallbackTarget), + syncAttrs: true + }); + return p; + }); + + await provider.triggerClose('close-dismiss', fallbackTarget); + + expect(opts.open.current).toBe(false); + expect(opts.activeSnapPoint.current).toBeNull(); + expect(fallbackTarget.getAttribute('data-last-action')).toBe('dismissed'); + expect(fallbackTarget.getAttribute('data-state')).toBe('closed'); + expect(eventEngine.emit).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'close-dismiss', + family: 'emerge', + target: fallbackTarget + }) + ); + + cleanup(); + dom.dispose(); + }); + + it('keeps persistent drawers structurally open', () => { + const { dom } = installSomaHarness(); + const opts = createDrawerOpts(); + opts.variant.current = 'persistent'; + opts.open.current = false; + + const { result: provider, cleanup } = withEffectRoot(() => DrawerProvider.create(opts)); + + provider.handleOpen(); + expect(opts.open.current).toBe(false); + + opts.open.current = true; + provider.handleClose(); + expect(opts.open.current).toBe(true); + + cleanup(); + dom.dispose(); + }); +});