From 813c4c440cb875a22e159f756525321caabfe7b6 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 15 May 2026 22:58:00 +0200 Subject: [PATCH] Add color field provider coverage --- continue.md | 5 +- src/uix/soma/SOMA_ARCHITECTURE.md | 3 + .../color-field-provider.svelte.test.ts | 258 ++++++++++++++++++ 3 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 src/uix/soma/components/color-field/color-field-provider.svelte.test.ts diff --git a/continue.md b/continue.md index 41fd4c60b..1975efdf8 100644 --- a/continue.md +++ b/continue.md @@ -55,9 +55,11 @@ Actualizacion 2026-05-15: lugar del prop crudo. - Cobertura nueva para FileUpload: aceptación/rechazo de ficheros, dropzone, input oculto, items, progress, remove y clear. + - Cobertura nueva para ColorField: commit por segmentos, edición hex con + teclado, selector de formato, input oculto y foco de label via `ActiveDom`. - Validado por fase: - `npm run check` -> 0 errores, 0 warnings. - - `npm run test` -> 188 archivos, 2093 tests OK. + - `npm run test` -> 189 archivos, 2097 tests OK. - tests focales de Select/Popover/Toast/RangeCalendar -> 11 tests OK. - tests focales Dialog/Drawer/Popover/Select -> 10 tests OK. - `command-provider.svelte.test.ts` -> 2 tests OK. @@ -74,6 +76,7 @@ Actualizacion 2026-05-15: - `virtual-grid-provider.svelte.test.ts` -> 2 tests OK. - `number-field-provider.svelte.test.ts` -> 4 tests OK. - `file-upload-provider.svelte.test.ts` -> 4 tests OK. + - `color-field-provider.svelte.test.ts` -> 4 tests OK. - guardia `rg "from './.*provider\\.svelte'" src/uix/soma/components -g 'exports.ts'` -> solo queda `select/components/select-provider.svelte`, que es wrapper publico, no runtime provider. diff --git a/src/uix/soma/SOMA_ARCHITECTURE.md b/src/uix/soma/SOMA_ARCHITECTURE.md index 772e7b377..65f3e0945 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -486,6 +486,9 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A teclado spinbutton, triggers, props ARIA y scrubber. - `file-upload/file-upload-provider.svelte.test.ts` — aceptación/rechazo, dropzone, input oculto, items, progress y acciones remove/clear. +- `color-field/color-field-provider.svelte.test.ts` — commit por segmentos, + edición hex por teclado, selector de formato, input oculto y foco via + `ActiveDom`. Pendiente: seguir ampliando cobertura por familias de alto riesgo fuera del bloque fecha/hora, virtualización, inputs numericos y file upload. diff --git a/src/uix/soma/components/color-field/color-field-provider.svelte.test.ts b/src/uix/soma/components/color-field/color-field-provider.svelte.test.ts new file mode 100644 index 000000000..5a6dd2d81 --- /dev/null +++ b/src/uix/soma/components/color-field/color-field-provider.svelte.test.ts @@ -0,0 +1,258 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { tick } from 'svelte'; + +import { createActiveDom } from '$adom'; +import { + colorValueFromRgb, + DEFAULT_COLOR, + type ColorFormat, + type ColorValue, + type EditableColorSegmentPart +} from '$libs/color'; +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 { FieldProvider } from '../field/field-provider.svelte'; + +import { + ColorFieldFormatSelectProvider, + ColorFieldHiddenInputProvider, + ColorFieldInputProvider, + ColorFieldLabelProvider, + ColorFieldProvider, + createColorSegmentProvider +} from './color-field-provider.svelte'; +import type { ColorOnInvalid, ColorValidator } from './types'; + +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: { + ts: (key: string) => key + }, + logger: { + warn: vi.fn() + }, + runtime: (morfo: Morfo, sources: Omit) => + createSomaRuntime(morfo, { + dom, + translate: (key) => key, + ...sources + }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(FieldProvider, 'get').mockReturnValue(undefined); + vi.spyOn(ColorFieldProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom }; +} + +function createColorFieldOpts() { + return { + id: state('color-field-root'), + ref: state(null), + value: state(undefined), + placeholder: state(DEFAULT_COLOR), + format: state('rgb'), + allowedFormats: state(['hex', 'rgb', 'hsl']), + enableAlpha: state(false), + validate: state(undefined), + onInvalid: state(undefined), + disabled: state(false), + readonly: state(false), + readonlySegments: state([]), + required: state(false), + invalid: state(false), + errorMessageId: state(undefined), + dir: state<'ltr' | 'rtl'>('ltr') + }; +} + +function keyEvent(key: string, target: HTMLElement = document.createElement('span')) { + return { + key, + ctrlKey: false, + metaKey: false, + currentTarget: target, + preventDefault: vi.fn() + }; +} + +describe('ColorFieldProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('commits an RGB value only when the required editable segments are filled', () => { + const { dom } = installSomaHarness(); + const opts = createColorFieldOpts(); + + const { result: provider, cleanup } = withEffectRoot(() => ColorFieldProvider.create(opts)); + + provider.updateSegment('r', () => '10'); + provider.updateSegment('g', () => '20'); + expect(opts.value.current).toBeUndefined(); + + provider.updateSegment('b', () => '30'); + + expect(opts.value.current?.rgb).toEqual({ r: 10, g: 20, b: 30, a: 1 }); + expect(opts.value.current?.hex).toBe('#0a141e'); + expect(provider.formattedValue).toBe('rgb(10, 20, 30)'); + expect(provider.props).toMatchObject({ + dir: 'ltr', + 'data-format': 'rgb', + 'data-invalid': undefined + }); + + cleanup(); + dom.dispose(); + }); + + it('edits a hex segment with keyboard input and exposes spinbutton aria', () => { + const { dom } = installSomaHarness(); + const opts = createColorFieldOpts(); + opts.format.current = 'hex'; + + const { result: provider, cleanup } = withEffectRoot(() => ColorFieldProvider.create(opts)); + vi.spyOn(ColorFieldProvider.ctx, 'get').mockReturnValue(provider); + + const target = document.createElement('span'); + const segment = createColorSegmentProvider('hex', { + id: state('color-field-hex'), + ref: state(target) + }); + + for (const key of ['f', 'f', '6', '6', '0', '0']) { + (segment.props as { onkeydown: (event: unknown) => void }).onkeydown(keyEvent(key, target)); + } + + expect(opts.value.current?.hex).toBe('#ff6600'); + expect(segment.props).toMatchObject({ + role: 'spinbutton', + 'data-segment': 'hex', + 'data-placeholder': undefined, + 'aria-valuemin': 0, + 'aria-valuemax': 16777215, + 'aria-valuetext': '#ff6600' + }); + + cleanup(); + dom.dispose(); + }); + + it('switches format only within allowed formats and keeps select props locked when needed', () => { + const { dom } = installSomaHarness(); + const opts = createColorFieldOpts(); + opts.value.current = colorValueFromRgb({ r: 255, g: 128, b: 0, a: 1 }); + opts.allowedFormats.current = ['hex', 'hsl']; + + const { result: provider, cleanup } = withEffectRoot(() => ColorFieldProvider.create(opts)); + vi.spyOn(ColorFieldProvider.ctx, 'get').mockReturnValue(provider); + + const select = ColorFieldFormatSelectProvider.create({ + id: state('color-field-format'), + ref: state(null), + ariaLabel: state(undefined) + }); + + select.onchange({ target: { value: 'hsl' } } as never); + expect(opts.format.current).toBe('hsl'); + expect(provider.segmentValues.h).toBe('30'); + expect(provider.segmentValues.s).toBe('100'); + expect(provider.segmentValues.l).toBe('50'); + expect(select.options).toEqual(['hex', 'hsl']); + expect(select.props['data-locked']).toBeUndefined(); + + opts.allowedFormats.current = ['hsl']; + expect(select.isLocked).toBe(true); + expect(select.props['data-locked']).toBe(''); + provider.setFormat('rgb'); + expect(opts.format.current).toBe('hsl'); + + cleanup(); + dom.dispose(); + }); + + it('wires input, label focus and hidden form value through ActiveDom', async () => { + const { dom } = installSomaHarness(); + const opts = createColorFieldOpts(); + opts.value.current = colorValueFromRgb({ r: 1, g: 2, b: 3, a: 1 }); + opts.required.current = true; + const focus = vi.spyOn(dom, 'focus'); + + const field = document.createElement('div'); + const firstSegment = document.createElement('span'); + firstSegment.dataset.segment = 'r'; + field.appendChild(firstSegment); + + const labelNode = document.createElement('label'); + labelNode.id = 'color-label'; + const { result, cleanup } = withEffectRoot(() => { + const provider = ColorFieldProvider.create(opts); + vi.spyOn(ColorFieldProvider.ctx, 'get').mockReturnValue(provider); + provider.setFieldNode(field); + + const label = ColorFieldLabelProvider.create({ + id: state('color-label'), + ref: state(labelNode) + }); + provider.setLabelNode(labelNode); + + const input = ColorFieldInputProvider.create({ + id: state('color-field-input'), + ref: state(field), + name: state('brandColor'), + ariaLabel: state(undefined), + ariaLabelledBy: state(undefined) + }); + const hidden = ColorFieldHiddenInputProvider.create({ + id: state('color-field-hidden') + }); + + return { provider, label, input, hidden }; + }); + + await tick(); + + expect(result.input.segments.map((segment) => segment.part)).toEqual([ + 'r', + 'literal', + 'g', + 'literal', + 'b' + ]); + expect(result.input.props).toMatchObject({ + role: 'group', + 'aria-labelledby': 'color-label', + 'aria-disabled': 'false', + 'data-format': 'rgb' + }); + expect(result.hidden.props).toMatchObject({ + type: 'hidden', + name: 'brandColor', + value: 'rgb(1, 2, 3)', + required: true, + 'aria-hidden': 'true' + }); + + result.label.onclick(); + expect(focus).toHaveBeenCalledWith(firstSegment); + + cleanup(); + dom.dispose(); + }); +});