diff --git a/continue.md b/continue.md index 1e8eb50fa..31c471c4a 100644 --- a/continue.md +++ b/continue.md @@ -91,9 +91,11 @@ Actualizacion 2026-05-15: list/content/indicator. - Cobertura nueva para Slider: contrato provider/range/thumb/tick, snapping/clamping, drag pointer confirmado y teclado de thumb. + - Cobertura nueva para Progress y Meter: `data-value/data-min/data-max` + emitidos por morfo/runtime, estados derivados e indicador sincronizado. - Validado por fase: - `npm run check` -> 0 errores, 0 warnings. - - `npm run test` -> 205 archivos, 2166 tests OK. + - `npm run test` -> 207 archivos, 2170 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. @@ -127,6 +129,8 @@ Actualizacion 2026-05-15: - `toggle-group-provider.svelte.test.ts` -> 4 tests OK. - `tabs-provider.svelte.test.ts` -> 4 tests OK. - `slider-provider.svelte.test.ts` -> 4 tests OK. + - `progress-provider.svelte.test.ts` + `meter-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 93936a69a..6e7cf31b7 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -522,6 +522,9 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A automatic/manual, navegacion roving, fallback tab stop y partes auxiliares. - `slider/slider-provider.svelte.test.ts` — contrato provider/range/thumb/tick, snapping/clamping, drag pointer confirmado y teclado de thumb. +- `progress/progress-provider.svelte.test.ts` y + `meter/meter-provider.svelte.test.ts` — `data-value/data-min/data-max` + emitidos por morfo/runtime, estados derivados e indicador sincronizado. Pendiente: seguir ampliando cobertura por el resto del catalogo Soma, ya por componentes de riesgo medio y familias menos centrales. `table-core`, diff --git a/src/uix/soma/components/meter/meter-provider.svelte.test.ts b/src/uix/soma/components/meter/meter-provider.svelte.test.ts new file mode 100644 index 000000000..f4bb8fbcb --- /dev/null +++ b/src/uix/soma/components/meter/meter-provider.svelte.test.ts @@ -0,0 +1,150 @@ +// @vitest-environment jsdom + +import { tick } from 'svelte'; +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 SomaRuntimeSources } from '$soma/runtime.svelte'; + +import { MeterIndicatorProvider, MeterProvider } from './meter-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: { + ts: vi.fn(() => 'Meter') + }, + runtime: (morfo: Morfo, sources: Omit) => + createSomaRuntime(morfo, { + dom, + translate: (key) => key, + ...sources + }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(MeterProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom }; +} + +function meterOpts(root = document.createElement('div')) { + return { + id: state('meter-root'), + ref: state(root), + value: state(50), + min: state(0), + max: state(100), + low: state(30), + high: state(70), + optimum: state(undefined), + ariaLabel: state(undefined), + ariaLabelledby: state(undefined), + valueText: state(undefined) + }; +} + +describe('MeterProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('projects meter value attrs and indicator zone through the runtime', async () => { + const { dom } = installSomaHarness(); + const root = document.createElement('div'); + const indicatorEl = document.createElement('div'); + document.body.append(root, indicatorEl); + const opts = meterOpts(root); + + const { result, cleanup } = withEffectRoot(() => { + const provider = MeterProvider.create(opts); + vi.spyOn(MeterProvider, 'require').mockReturnValue(provider); + const indicator = MeterIndicatorProvider.create({ + id: state('meter-indicator'), + ref: state(indicatorEl) + }); + return { provider, indicator }; + }); + + expect(result.provider.props).toMatchObject({ + id: 'meter-root', + 'data-meter': '' + }); + expect(result.provider.props.style).toEqual({ + '--soma-meter-value-pct': '50' + }); + expect(result.indicator.props).toMatchObject({ + id: 'meter-indicator', + 'data-meter-indicator': '' + }); + + await tick(); + expect(root.getAttribute('role')).toBe('meter'); + expect(root.getAttribute('data-state')).toBe('optimum'); + expect(root.getAttribute('data-value')).toBe('50'); + expect(root.getAttribute('data-min')).toBe('0'); + expect(root.getAttribute('data-max')).toBe('100'); + expect(root.getAttribute('aria-valuenow')).toBe('50'); + expect(root.getAttribute('aria-valuetext')).toBe('50 / 100'); + expect(root.getAttribute('aria-label')).toBe('Meter'); + expect(indicatorEl.getAttribute('data-state')).toBe('optimum'); + + opts.value.current = 20; + await tick(); + expect(result.provider.state).toBe('below'); + expect(root.getAttribute('data-state')).toBe('below'); + expect(indicatorEl.getAttribute('data-state')).toBe('below'); + + opts.value.current = 90; + await tick(); + expect(result.provider.state).toBe('above'); + expect(root.getAttribute('data-state')).toBe('above'); + expect(indicatorEl.getAttribute('data-state')).toBe('above'); + + cleanup(); + dom.dispose(); + }); + + it('resolves defaults, clamps percentage and supports labelledby/valueText', async () => { + const { dom } = installSomaHarness(); + const root = document.createElement('div'); + document.body.appendChild(root); + const opts = meterOpts(root); + opts.value.current = 150; + opts.low.current = undefined; + opts.high.current = undefined; + opts.ariaLabelledby.current = 'meter-title'; + opts.valueText.current = 'Over target'; + + const { result: provider, cleanup } = withEffectRoot(() => MeterProvider.create(opts)); + + expect(provider.resolvedLow).toBe(0); + expect(provider.resolvedHigh).toBe(100); + expect(provider.resolvedOptimum).toBe(50); + expect(provider.percentage).toBe(100); + expect(provider.props.style).toEqual({ + '--soma-meter-value-pct': '100' + }); + + await tick(); + expect(root.hasAttribute('aria-label')).toBe(false); + expect(root.getAttribute('aria-labelledby')).toBe('meter-title'); + expect(root.getAttribute('aria-valuetext')).toBe('Over target'); + + cleanup(); + dom.dispose(); + }); +}); diff --git a/src/uix/soma/components/progress/progress-provider.svelte.test.ts b/src/uix/soma/components/progress/progress-provider.svelte.test.ts new file mode 100644 index 000000000..d6865bb7a --- /dev/null +++ b/src/uix/soma/components/progress/progress-provider.svelte.test.ts @@ -0,0 +1,141 @@ +// @vitest-environment jsdom + +import { tick } from 'svelte'; +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 SomaRuntimeSources } from '$soma/runtime.svelte'; + +import { ProgressIndicatorProvider, ProgressProvider } from './progress-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: { + ts: vi.fn(() => 'Loading') + }, + runtime: (morfo: Morfo, sources: Omit) => + createSomaRuntime(morfo, { + dom, + translate: (key) => key, + ...sources + }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(ProgressProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom }; +} + +function progressOpts(root = document.createElement('div')) { + return { + id: state('progress-root'), + ref: state(root), + value: state(40), + min: state(0), + max: state(100), + ariaLabel: state(undefined), + ariaLabelledby: state(undefined), + valueText: state(undefined) + }; +} + +describe('ProgressProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('projects progress value attrs and indicator state through the runtime', async () => { + const { dom } = installSomaHarness(); + const root = document.createElement('div'); + const indicatorEl = document.createElement('div'); + document.body.append(root, indicatorEl); + const opts = progressOpts(root); + + const { result, cleanup } = withEffectRoot(() => { + const provider = ProgressProvider.create(opts); + vi.spyOn(ProgressProvider, 'require').mockReturnValue(provider); + const indicator = ProgressIndicatorProvider.create({ + id: state('progress-indicator'), + ref: state(indicatorEl) + }); + return { provider, indicator }; + }); + + expect(result.provider.props).toMatchObject({ + id: 'progress-root', + 'data-progress': '' + }); + expect(result.provider.props.style).toEqual({ + '--soma-progress-value-pct': '40' + }); + expect(result.indicator.props).toMatchObject({ + id: 'progress-indicator', + 'data-progress-indicator': '' + }); + + await tick(); + expect(root.getAttribute('role')).toBe('progressbar'); + expect(root.getAttribute('data-state')).toBe('loading'); + expect(root.getAttribute('data-value')).toBe('40'); + expect(root.getAttribute('data-min')).toBe('0'); + expect(root.getAttribute('data-max')).toBe('100'); + expect(root.getAttribute('aria-valuenow')).toBe('40'); + expect(root.getAttribute('aria-valuetext')).toBe('40 / 100'); + expect(root.getAttribute('aria-label')).toBe('Loading'); + expect(indicatorEl.getAttribute('data-state')).toBe('loading'); + + opts.value.current = null; + await tick(); + expect(result.provider.state).toBe('indeterminate'); + expect(result.provider.props.style).toEqual({ + '--soma-progress-value-pct': '0' + }); + expect(root.getAttribute('data-state')).toBe('indeterminate'); + expect(root.hasAttribute('data-value')).toBe(false); + expect(root.hasAttribute('aria-valuenow')).toBe(false); + expect(indicatorEl.getAttribute('data-state')).toBe('indeterminate'); + + opts.value.current = 100; + await tick(); + expect(result.provider.state).toBe('loaded'); + expect(root.getAttribute('data-state')).toBe('loaded'); + expect(indicatorEl.getAttribute('data-state')).toBe('loaded'); + + cleanup(); + dom.dispose(); + }); + + it('uses labelledby instead of fallback label when provided', async () => { + const { dom } = installSomaHarness(); + const root = document.createElement('div'); + document.body.appendChild(root); + const opts = progressOpts(root); + opts.ariaLabelledby.current = 'progress-title'; + opts.valueText.current = 'Forty percent'; + + const { cleanup } = withEffectRoot(() => ProgressProvider.create(opts)); + + await tick(); + expect(root.hasAttribute('aria-label')).toBe(false); + expect(root.getAttribute('aria-labelledby')).toBe('progress-title'); + expect(root.getAttribute('aria-valuetext')).toBe('Forty percent'); + + cleanup(); + dom.dispose(); + }); +});