From efa1cbe891f202261a0f792519428212e23259df Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 27 Sep 2026 01:53:13 +0200 Subject: [PATCH] audit(uix): characterize AudioPlayer without changing playback --- docs/process/PLAN-uix-quality-2026-09-19.md | 26 ++++ .../audit-uix-2026-09-19/audio-player.md | 110 +++++++++++++ .../audit-uix-2026-09-19/findings-register.md | 38 +++++ .../eidos/components/audio-player/README.md | 29 ++++ .../audio-player/audio-player.svelte.test.ts | 144 ++++++++++++++++++ .../test/audio-player-audit-harness.svelte | 80 ++++++++++ .../test/audio-player-audit-view.ts | 95 ++++++++++++ 7 files changed, 522 insertions(+) create mode 100644 docs/process/audit-uix-2026-09-19/audio-player.md create mode 100644 src/uix/eidos/components/audio-player/audio-player.svelte.test.ts create mode 100644 src/uix/eidos/components/audio-player/test/audio-player-audit-harness.svelte create mode 100644 src/uix/eidos/components/audio-player/test/audio-player-audit-view.ts diff --git a/docs/process/PLAN-uix-quality-2026-09-19.md b/docs/process/PLAN-uix-quality-2026-09-19.md index a8d01725f..71a5a6003 100644 --- a/docs/process/PLAN-uix-quality-2026-09-19.md +++ b/docs/process/PLAN-uix-quality-2026-09-19.md @@ -1089,6 +1089,32 @@ siempre presentes, sin cambiar el contrato opcional del provider. No se acredita lector de pantalla, touch, otros motores, decode ni transporte/reproducción de audio. +### Skin de audio: AudioPlayer (2026-09-27) + +La [ficha de AudioPlayer](./audit-uix-2026-09-19/audio-player.md) contrasta el +skin, las capas actuales de MediaPlayer, transporte Sound, recetas y decisiones +firmadas. Las referencias primarias son Vidstack, Media Chrome, Video.js y Plyr; +APG Slider y Media Seek se aplican a los controles compuestos. AudioPlayer ya +reutiliza el motor; no se fuerza otro helper para su derivación corta de metadata. +**Cero líneas de producción reducidas**, sin modificar CSS ni comportamiento. + +Cuatro canarios con servicios reales y WAV PCM local silencioso cubren carga, +play/pause/mute, callbacks, metadata derivada y explícita, teclado, volumen, +reinicio, velocidad, variantes, tallas, peaks y dirección explícita. Pasan +22/22 junto al provider existente; la baseline separada pasa 39/39 en tres +archivos. Se inspeccionan cinco capturas completas a escala nativa, ampliando +el viewport de la prueba para el escenario ancho. Una expectativa inicial +de data-size y el recorte del iframe se corrigen en el fixture, no en producción. + +Las sondas reproducen Q86 (tres thumbs sin nombre accesible) y Q87 (sin +texto temporal con y sin peaks). Se repiten con 22 canarios verdes, se retiran +y se conservan sus artefactos. Quedan para después de la auditoría. El inventario +alcanza **70 fichas**, con fase 6 abierta; MediaPlayer requiere su propio lote. +El gate de cierre pasa 509/509 archivos y 5607/5607 tests, src/scripts sin +errores, 896 docs sin errores ni warnings y apps/base con check/build y smoke +27/27 sin errores de página/consola/CSP. No se acredita sonido físico, tecnología de +asistencia, teclas de medios del sistema, pantalla de bloqueo, PiP ni vídeo. + ### Revisión de Chronos (2026-09-22) El provider contiene 1.723 líneas físicas no vacías, pero su volumen no procede diff --git a/docs/process/audit-uix-2026-09-19/audio-player.md b/docs/process/audit-uix-2026-09-19/audio-player.md new file mode 100644 index 000000000..9d29e7417 --- /dev/null +++ b/docs/process/audit-uix-2026-09-19/audio-player.md @@ -0,0 +1,110 @@ +--- +title: UIX — auditoría de AudioPlayer +type: notes +audience: human + agent +authority: process — caracterización del skin existente, sin cambios de comportamiento +status: reviewed +--- + +# AudioPlayer + +Fecha: 2026-09-27. Alcance autorizado: duplicación, refactorización neutral y +registro de defectos; web queda fuera. Se leen los cuatro documentos de +pre-flight, los cinco archivos del skin, el contrato Eidos, el plan v2 firmado, +la ficha histórica de MediaPlayer y las decisiones de su hilo de sonido. +También se leen las capas y wrappers actuales de MediaPlayer, su puerto, +el transporte nativo del servicio Sound y las traducciones. + +## Baseline y referencias + +No hay componente Air media-player/audio-player en morfo-runtime. La ficha de +julio describe un contrato anterior; el Morfo vigente tiene 25 partes y once +eventos, expresión pack. AudioPlayer no crea otro motor: compone MediaPlayer +y AudioLayout sobre ese mismo contrato. La decisión sigue vigente. + +| Referencia primaria | Superficie relevante | Decisión de esta auditoría | +| ------------------------------------------------------ | -------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| [Vidstack](https://vidstack.io/docs/player/) | Proveedores, controles compuestos, audio y video, accesibilidad y layouts. | Conservar puerto y composición existentes; no importar otro motor. | +| [Media Chrome](https://github.com/muxinc/media-chrome) | Controles separados sobre audio/video nativos. | Conservar controles sobre estado del medio; no duplicar el transporte. | +| [Video.js](https://videojs.org/) | Separación de UI y medio mediante contratos públicos. | Comparar responsabilidades; no ampliar los adaptadores ni calidad. | +| [Plyr](https://github.com/sampotts/plyr) | Audio, transporte, volumen, velocidad y controles configurables. | Caracterizar los controles disponibles sin incorporar funciones ajenas. | + +Consulta de fuentes primarias realizada el 2026-09-27. La URL documental +antigua de Media Chrome no responde al lector; se usa su README oficial. +Video.js redirige su dominio anterior; su sitio ya presenta v10 RC y Plyr +anuncia la convergencia futura. La comparación es de superficie, no una +recomendación de migración. El [APG Slider](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) +se aplica a los controles embebidos, no a un supuesto patrón APG de player. + +## Arquitectura y decisiones preservadas + +AudioPlayer es una raíz Eidos competidora. Sus cuatro variantes card/row/bar/inline +y sus tallas sm/md/lg/xl son visuales; el estado y los eventos siguen en +MediaPlayer/Soma y el transporte en Sound. La identidad visible deriva metadata +salvo override explícito. Ese dato se captura al registrar el medio, según el +contrato vigente. Una prop undefined con identidad visible no desactiva esa +derivación; no se inventa una nueva política durante la auditoría. + +Sin peaks queda el raíl; con peaks TimeSlider compone Waveform. No hay onda +genérica ni descarga/decodificación de picos desde el skin. El buffered sigue +siendo SecondaryRange del Slider. RateFloat usa los presets del provider. + +El hilo histórico documenta aprobación de las cuatro variantes y el suelo del +scrubber de bar. Su ancho mínimo de diseño y el mute estrujado por debajo del +umbral tienen disposición previa. Se conserva ese diseño; una futura container +query y orden de descarte son trabajo funcional separado. + +## Duplicación y candidatos + +El skin tiene un único punto de composición y no replica el motor. Extraer su +derivación corta de metadata a otro helper no acredita reducción ni un segundo +consumidor real. Las conexiones repetidas de MediaPlayer y sus partes se +evaluarán en su propio lote, manteniendo createProvider capturado y separado +de la bolsa reactiva. La base común de partes pasivas ya existe. + +## Caracterización y hallazgos + +La línea base existente pasa **39/39 tests en tres archivos**: provider de +MediaPlayer, Morfo y transporte del servicio Sound. component:audit de +MediaPlayer devuelve PASS sin errores. Esto no certifica todo AudioPlayer. + +Se incorporan cuatro canarios con ActiveUix/Soma/ActiveEidos reales y un WAV +PCM local de veinte segundos, con muestras silenciosas. Preparan carga, +play/pause/mute, callbacks, metadata, cuatro variantes, tallas, dirección, +peaks, teclado/volumen sin fuga de flechas, reinicio, velocidad y override +de identidad. La primera ejecución pasa 21/22 y falla por esperar data-size +en el contenedor de TimeSlider: Soma lo reenvía al Slider interior. Se corrige +únicamente esa expectativa; la repetición pasa **22/22 en dos archivos**. +Las capturas iniciales recortan el escenario ancho por el iframe de 414px; +se amplía explícitamente el viewport de la prueba a 1100×1000 y se repiten: +**4/4 pasan**. Se inspeccionan las cinco capturas a escala nativa, completas: +card/row/bar/inline y row RTL con Waveform. Los botones adicionales de reinicio +y salto son children del escenario. No se confunde esa composición con la +salida mínima del skin. + +Tres sondas de nombre accesible confirman Q86: Tiempo, Volumen y Tiempo con +Waveform tienen etiqueta en el root Slider, pero sus thumbs reciben nombre +vacío. Q87 registra aria-valuetext ausente en el control de tiempo después +de seek nativo a 10 segundos sobre duración 20. El dominio numérico sigue +correcto. Se contrasta el [ejemplo APG Media Seek](https://www.w3.org/WAI/ARIA/apg/patterns/slider/examples/slider-seek/). +La primera sonda de texto recibe null y toMatch rechaza ese tipo; se precisa +la expectativa de presencia y se añade la rama con peaks. La repetición pasa +**22/22 canarios en dos archivos** y fallan las cinco sondas en otro archivo +con los tres nombres vacíos y los dos atributos null. Se retiran las sondas +y se preservan fuentes, logs y capturas en tmp. Q86/Q87 quedan en el registro +para después de la auditoría, sin reparación funcional. + +La colisión potencial del temporizador idle requiere su propia reproducción +con varios MediaPlayers. No se da por confirmada ni se cierra ese lote por +haber probado este skin. + +No se modifica código ejecutable de producción: **0 líneas reducidas**. +Se mantienen cuatro canarios y se precisa el README del skin. El gate de +cierre termina verde: **509/509 archivos y 5607/5607 tests**, tipos sin errores +en src/scripts, 896 docs sin errores ni warnings, arquitectura sin errores y +apps/base con check/build y smoke 27/27, sin errores de página/consola/CSP. +La revisión final retira una sección documental duplicada durante la edición; +se repiten formato y guards de documentación, sin cambios ejecutables. + +No se acredita sonido físico, teclas del sistema, pantalla de bloqueo, +lector de pantalla, touch, otros motores, PiP ni reproducción de vídeo. diff --git a/docs/process/audit-uix-2026-09-19/findings-register.md b/docs/process/audit-uix-2026-09-19/findings-register.md index 8d479238c..1aa4d854f 100644 --- a/docs/process/audit-uix-2026-09-19/findings-register.md +++ b/docs/process/audit-uix-2026-09-19/findings-register.md @@ -167,6 +167,13 @@ Lote posterior de Waveform: | ------------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | Nombre del scrubber | Q85 | Hacer llegar el nombre propio o localizado al thumb del Slider compuesto; cubrir teclado y valuetext sin añadir ARIA al div genérico de Waveform. | +Lotes posteriores de los controles MediaPlayer usados por AudioPlayer: + +| Lote posterior | ID | Condición y prueba de cierre | +| --------------------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Nombres de tiempo y volumen | Q86 | Nombrar los thumbs de TimeSlider con y sin peaks y de VolumeSlider; cubrir traducciones, teclado y composición independiente del skin. | +| Texto accesible del tiempo | Q87 | Adjudicar y conectar el formato temporal en ambos scrubbers, conservar dominio numérico y probar cambios de tiempo/duración e idiomas con tecnología de asistencia. | + Al aparecer otro defecto durante la auditoría se añade aquí y en la tabla de evidencia de abajo con un ID existente del ledger o un `Q*` nuevo, reproducción o límite de la evidencia, consumidor afectado y prueba de cierre. Al terminar @@ -261,6 +268,37 @@ Ninguno entra en la cola de correcciones confirmadas por inferencia. | Q73 | Cropper puntero | Down ID1 en Selection y move ID2 de 32px desplazan crop.x de 0.25 a 0.35; la sesión no conserva pointerId. | Chromium montado, eventos sintéticos y captura mock; sonda retirada | Cubrir selección, handle y pan; reproducir multi-touch nativo. | | Q74 | Cropper metadatos | apg afirma keyboard nudge desde C5, pero Selection/Handle no tienen handler de teclado y README lo difiere. El campo de metadatos se conserva. | Discrepancia confirmada por lectura; efecto en consumidores de metadatos sin reproducir | Adjudicar y alinear la justificación; no implementar teclado por inferencia. | +### AudioPlayer y controles MediaPlayer: Q86–Q87 + +Fecha: 2026-09-27. Un consumidor Eidos monta ActiveUix, Soma, ActiveEidos y +Sound reales, con un WAV PCM local de veinte segundos y muestras silenciosas. +Se prueban los controles compartidos que AudioLayout monta; la causa está en +las composiciones Soma de MediaPlayer, no en un motor nuevo de AudioPlayer. + +**Q86 — nombres de sliders**: los roots Slider tienen aria-label Buscar o +Volumen, pero sus thumbs con role slider reciben nombre accesible vacío. Tres +sondas con matcher de nombre fallan: tiempo sin peaks, volumen y tiempo con +Waveform. Es una ocurrencia distinta de la pauta reproducida en Q70/Q77/Q85; +no se atribuye por inferencia otro defecto al motor Slider. El cierre contrasta +[APG Slider](https://www.w3.org/WAI/ARIA/apg/patterns/slider/). + +**Q87 — texto temporal**: tras la tecla nativa 5, el audio queda en 10 segundos, +el thumb publica aria-valuenow=10 y aria-valuemax=20, pero no tiene +aria-valuetext. La ausencia se contrasta con el ejemplo primario +[APG Media Seek](https://www.w3.org/WAI/ARIA/apg/patterns/slider/examples/slider-seek/), +que explica el formato temporal de segundos como ayuda a la comprensión. +Es un gap de accesibilidad del consumidor, no un fallo de seek. No se impone +por inferencia una frase española exacta ni se afirma qué anuncia un lector. + +En la primera ejecución pasan los 22 canarios de otros dos archivos y fallan +las cuatro sondas. La sonda de texto encuentra null y el matcher toMatch +rechaza su tipo; se precisa primero la expectativa de presencia y se amplía +la prueba a las dos ramas, sin editar producción. La repetición pasa los mismos +22 canarios y fallan las cinco sondas: tres nombres vacíos y dos atributos +temporales null. Se retiran las sondas y se conservan sus fuentes, logs y +capturas en tmp. No se acredita tecnología de asistencia ni touch con estas sondas. +Q86 y Q87 permanecen abiertos para después de la auditoría. + ### Waveform: Q85 Fecha: 2026-09-27. **Q85 — Waveform ARIA**: el thumb con role slider recibe diff --git a/src/uix/eidos/components/audio-player/README.md b/src/uix/eidos/components/audio-player/README.md index fdf06e555..3073fe522 100644 --- a/src/uix/eidos/components/audio-player/README.md +++ b/src/uix/eidos/components/audio-player/README.md @@ -54,3 +54,32 @@ puede escribir, y el guard R-5.4 comprueba que ninguna miente. - `live-font-weight` — the LIVE badge only exists while the provider reflects a live stream (display:none otherwise) and the demo plays a file; mounted with [data-live] 2026-08-23 -> reaches (600 -> 333) - `live-letter-spacing` — same badge; mounted -> reaches (1.28px -> 7px) + +## Auditoría del skin (2026-09-27) + +La [ficha de auditoría](../../../../../docs/process/audit-uix-2026-09-19/audio-player.md) +contrasta las referencias de reproductores y las decisiones firmadas, y conserva +la composición sobre MediaPlayer. No se añade otro motor ni se cambia la receta. +No se extrae la derivación corta de metadata: no hay un segundo consumidor que +justifique otro helper. Reducción de código ejecutable en este skin: **0 líneas**. + +Cuatro canarios mantenidos montan ActiveUix/Soma/ActiveEidos y el transporte Sound +reales con un WAV PCM local silencioso. Cubren carga, reproducción/pausa/mute, +callbacks, metadata derivada y explícita, teclado, volumen, reinicio, presets de +velocidad, las cuatro variantes, tallas y dirección explícita. Con `peaks`, +TimeSlider compone Waveform; sin ellos conserva el Slider de raíl. Cambiar variante +mantiene el mismo elemento audio. Las capturas se inspeccionan a escala nativa +con viewport de prueba ancho, incluidos controles adicionales por `children`. + +La metadata se proyecta al registrar el medio en Sound. Actualizar sólo el título +visible no implica actualizar una metadata explícita ya registrada. No se amplía +esa política en la auditoría. Las decisiones previas sobre el ancho de bar y su +descarte responsive permanecen vigentes. + +Las sondas de los controles compartidos registran Q86 (nombre vacío de los thumbs) +y Q87 (falta de texto temporal en el scrubber) para después de la auditoría. +El estado y la prueba de cierre viven en el +[registro](../../../../../docs/process/audit-uix-2026-09-19/findings-register.md). +El gate o el resultado de los canarios no dan estos defectos por reparados. +No se acredita lector de pantalla, touch asistido, sonido físico, teclas del +sistema, pantalla de bloqueo, PiP ni reproducción de vídeo con este fixture. diff --git a/src/uix/eidos/components/audio-player/audio-player.svelte.test.ts b/src/uix/eidos/components/audio-player/audio-player.svelte.test.ts new file mode 100644 index 000000000..af46336d3 --- /dev/null +++ b/src/uix/eidos/components/audio-player/audio-player.svelte.test.ts @@ -0,0 +1,144 @@ +import { beforeAll, expect, it, vi } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; +import { renderAudioPlayer } from './test/audio-player-audit-view'; + +beforeAll(async () => { + await page.viewport(1100, 1000); + await userEvent.keyboard('{Shift}'); +}, 60_000); + +it('reflects native play, pause, mute and source metadata through the real sound service', async () => { + const view = renderAudioPlayer(); + try { + await view.ready(); + expect(view.root.getAttribute('role')).toBe('region'); + await expect.element(view.root).toHaveAccessibleName('Reproductor de prueba'); + expect(view.root.getAttribute('data-media')).toBe('audio'); + expect(view.audio.preload).toBe('metadata'); + expect(view.audio.controls).toBe(false); + expect(view.part('time').textContent?.trim()).toBe('0:00'); + await expect.element(view.part('play-button')).toHaveAccessibleName('Reproducir'); + let trusted = false; + view.part('play-button').addEventListener('click', (event) => (trusted = event.isTrusted)); + await userEvent.click(view.part('play-button')); + await vi.waitFor(() => expect(view.audio.paused).toBe(false)); + await expect.element(view.part('play-button')).toHaveAccessibleName('Pausar'); + expect(trusted).toBe(true); + expect(view.root.hasAttribute('data-paused')).toBe(false); + expect(view.onPlay).toHaveBeenCalledOnce(); + expect(navigator.mediaSession.metadata?.title).toBe('Pista de prueba'); + expect(navigator.mediaSession.metadata?.artist).toBe('Autor de prueba'); + await userEvent.click(view.part('mute-button')); + await vi.waitFor(() => expect(view.audio.muted).toBe(true)); + expect(view.part('mute-button').getAttribute('aria-pressed')).toBe('true'); + await expect.element(view.part('mute-button')).toHaveAccessibleName('Activar sonido'); + await userEvent.click(view.part('play-button')); + await vi.waitFor(() => expect(view.audio.paused).toBe(true)); + expect(view.onPause).toHaveBeenCalledOnce(); + expect(view.onEnded).not.toHaveBeenCalled(); + await userEvent.click(view.button('Toggle loop')); + expect(view.audio.loop).toBe(true); + } finally { + await view.dispose(); + } +}, 30_000); + +it('keeps layout variants and control sizes on one media element', async () => { + const view = renderAudioPlayer(); + try { + await view.ready(); + const audio = view.audio; + expect(window.innerWidth).toBe(1100); + for (const variant of ['card', 'row', 'bar', 'inline']) { + await userEvent.click(view.button(`Variant ${variant}`)); + expect(view.root.getAttribute('data-variant')).toBe(variant); + expect(view.root.querySelector('audio')).toBe(audio); + expect(getComputedStyle(view.part('identity')).display === 'none').toBe(variant === 'inline'); + expect(getComputedStyle(view.part('mute-button')).display === 'none').toBe( + variant === 'inline' + ); + expect(getComputedStyle(view.part('artist')).display === 'none').toBe(variant === 'bar'); + if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1') + await page.screenshot({ + element: view.root, + path: `../../../../../tmp/audio-player-${variant}.png` + }); + } + await userEvent.click(view.button('Variant row')); + for (const size of ['sm', 'md', 'lg', 'xl']) { + await userEvent.click(view.button(`Size ${size}`)); + expect(view.root.getAttribute('data-size')).toBe(size); + expect(view.part('play-button').getAttribute('data-size')).toBe(size); + expect( + view.part('time-slider').querySelector('[data-slider]')?.getAttribute('data-size') + ).toBe(size); + } + await userEvent.click(view.button('Toggle peaks')); + expect(view.part('time-slider').querySelector('[data-waveform]')).not.toBeNull(); + await userEvent.click(view.button('Toggle direction')); + expect(view.root.getAttribute('dir')).toBe('rtl'); + expect(view.root.getAttribute('data-dir')).toBe('rtl'); + expect(getComputedStyle(view.part('play-button').querySelector('svg')!).scale).toBe('-1 1'); + expect(view.root.querySelector('[data-slider]')?.getAttribute('dir')).toBe('rtl'); + if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1') + await page.screenshot({ + element: view.root, + path: '../../../../../tmp/audio-player-row-rtl-waveform.png' + }); + expect(view.onPlay).not.toHaveBeenCalled(); + expect(view.onPause).not.toHaveBeenCalled(); + } finally { + await view.dispose(); + } +}, 30_000); + +it('seeks and changes volume through native keyboard without leaking slider arrows', async () => { + const view = renderAudioPlayer(); + try { + await view.ready(); + view.root.focus(); + await userEvent.keyboard('{ArrowRight}'); + await vi.waitFor(() => expect(view.audio.currentTime).toBeCloseTo(3)); + await userEvent.keyboard('{ArrowLeft}'); + await vi.waitFor(() => expect(view.audio.currentTime).toBeCloseTo(0)); + await userEvent.keyboard('5'); + await vi.waitFor(() => expect(view.audio.currentTime).toBeCloseTo(10)); + const volume = view.part('volume-slider').querySelector('[data-slider-thumb]')!; + volume.focus(); + await userEvent.keyboard('{Home}'); + await vi.waitFor(() => expect(view.audio.volume).toBe(0)); + expect(view.audio.currentTime).toBeCloseTo(10); + await userEvent.keyboard('{ArrowRight}'); + await vi.waitFor(() => expect(view.audio.volume).toBeCloseTo(0.01)); + expect(view.audio.currentTime).toBeCloseTo(10); + await userEvent.click(view.part('restart-button')); + await vi.waitFor(() => expect(view.audio.currentTime).toBeCloseTo(0)); + expect((view.part('skip-button') as HTMLButtonElement).disabled).toBe(true); + await userEvent.click(view.part('rate-button')); + const slower = page.getByRole('menuitemradio', { name: '0.5×', exact: true }); + await slower.click(); + await vi.waitFor(() => expect(view.audio.playbackRate).toBe(0.5)); + expect(view.part('rate-button').getAttribute('data-rate')).toBe('0.5'); + expect(view.part('rate-button').textContent?.trim()).toBe('0.5×'); + } finally { + await view.dispose(); + } +}, 30_000); + +it('preserves explicitly supplied session metadata instead of deriving the visible identity', async () => { + const view = renderAudioPlayer({ title: 'Identidad explícita', artist: 'Autor explícito' }); + try { + await view.ready(); + await userEvent.click(view.part('play-button')); + await vi.waitFor(() => + expect(navigator.mediaSession.metadata?.title).toBe('Identidad explícita') + ); + expect(navigator.mediaSession.metadata?.artist).toBe('Autor explícito'); + await userEvent.click(view.part('play-button')); + await userEvent.click(view.button('Change identity')); + expect(view.part('title').textContent?.trim()).toBe('Nueva pista'); + expect(navigator.mediaSession.metadata?.title).toBe('Identidad explícita'); + } finally { + await view.dispose(); + } +}, 30_000); diff --git a/src/uix/eidos/components/audio-player/test/audio-player-audit-harness.svelte b/src/uix/eidos/components/audio-player/test/audio-player-audit-harness.svelte new file mode 100644 index 000000000..5d8760f89 --- /dev/null +++ b/src/uix/eidos/components/audio-player/test/audio-player-audit-harness.svelte @@ -0,0 +1,80 @@ + + +
+

AudioPlayer audit

+ + + + + +
+ {#each ['card', 'row', 'bar', 'inline'] as nextVariant} + + {/each} + {#each ['sm', 'md', 'lg', 'xl'] as nextSize} + + {/each} + + + + +
+
diff --git a/src/uix/eidos/components/audio-player/test/audio-player-audit-view.ts b/src/uix/eidos/components/audio-player/test/audio-player-audit-view.ts new file mode 100644 index 000000000..0c2ae5e28 --- /dev/null +++ b/src/uix/eidos/components/audio-player/test/audio-player-audit-view.ts @@ -0,0 +1,95 @@ +import { flushSync, mount, unmount } from 'svelte'; +import { vi } from 'vitest'; +import { createActiveUix } from '$active-uix'; +import type { SoundMediaMetadata } from '$sound'; +import '../../../index.css'; +import AudioPlayerAuditHarness from './audio-player-audit-harness.svelte'; + +// A native PCM WAV with silent samples: media loading/playback is real, +// while the fixture emits no audible content and needs no network server. +function silentWav(seconds: number): Blob { + const rate = 8000; + const bytes = seconds * rate * 2; + const buffer = new ArrayBuffer(44 + bytes); + const data = new DataView(buffer); + const text = (offset: number, value: string) => { + for (let i = 0; i < value.length; i++) data.setUint8(offset + i, value.charCodeAt(i)); + }; + text(0, 'RIFF'); + data.setUint32(4, 36 + bytes, true); + text(8, 'WAVE'); + text(12, 'fmt '); + data.setUint32(16, 16, true); + data.setUint16(20, 1, true); + data.setUint16(22, 1, true); + data.setUint32(24, rate, true); + data.setUint32(28, rate * 2, true); + data.setUint16(32, 2, true); + data.setUint16(34, 16, true); + text(36, 'data'); + data.setUint32(40, bytes, true); + return new Blob([buffer], { type: 'audio/wav' }); +} + +export function renderAudioPlayer(metadata?: SoundMediaMetadata) { + const src = URL.createObjectURL(silentWav(20)); + const artworkSrc = + 'data:image/svg+xml,' + + encodeURIComponent( + '' + ); + const uix = createActiveUix({ + langs: { schema: {}, defaultLocale: 'es' }, + prefs: { storage: false }, + projectPrefs: false + }); + const target = document.createElement('div'); + document.body.appendChild(target); + const onPlay = vi.fn(); + const onPause = vi.fn(); + const onEnded = vi.fn(); + const component = mount(AudioPlayerAuditHarness, { + target, + props: { uix, src, artworkSrc, metadata, onPlay, onPause, onEnded } + }); + flushSync(); + const root = target.querySelector('[data-media-player]')!; + const audio = root.querySelector('audio')!; + return { + target, + root, + audio, + uix, + artworkSrc, + onPlay, + onPause, + onEnded, + part(name: string) { + return root.querySelector(`[data-media-player-${name}]`)!; + }, + button(text: string) { + return Array.from(target.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === text + )!; + }, + async ready() { + await vi.waitFor( + () => { + expectLoaded(audio); + }, + { timeout: 5000 } + ); + }, + async dispose() { + audio.pause(); + await unmount(component); + uix.dispose(); + URL.revokeObjectURL(src); + target.remove(); + } + }; +} + +function expectLoaded(audio: HTMLAudioElement) { + if (audio.readyState < 3 || audio.duration !== 20) throw new Error('Native audio not ready'); +}