test(webui): add vitest parser tests and playwright ui coverage

This commit is contained in:
Clément SAILLANT
2026-02-22 03:08:49 +01:00
parent c2a4570cb2
commit cc0a106b03
5 changed files with 262 additions and 21 deletions
@@ -43,13 +43,13 @@ test('@mock detects freenove_legacy and limits unsupported actions', async ({ pa
await page.goto('/')
await expect(page.getByText('Freenove Legacy API')).toBeVisible()
await expect(page.getByText('API Legacy')).toBeVisible()
await expect(
page.getByText('Legacy mode detected. Scenario selection and start are unavailable, but you can monitor'),
page.getByText('Mode legacy detecte: selection/start sont indisponibles.'),
).toBeVisible()
await page.getByRole('button', { name: 'Open monitor' }).first().click()
await expect(page.getByRole('heading', { name: 'Live Orchestrator' })).toBeVisible()
await page.getByRole('button', { name: 'Ouvrir monitor' }).first().click()
await expect(page.getByRole('heading', { name: 'Orchestrateur live' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Pause' })).toBeDisabled()
await page.getByRole('button', { name: 'Skip' }).click()
@@ -75,12 +75,9 @@ test('@mock keeps Story Designer in read/edit mode for legacy APIs', async ({ pa
})
await page.goto('/')
await page.getByRole('button', { name: 'Story Designer' }).click()
await page.getByRole('button', { name: 'Designer' }).click()
await expect(
page.getByText('Story Designer is in read/edit mode. Validate/deploy actions require Story V2 API support.'),
).toBeVisible()
await expect(page.getByRole('button', { name: 'Validate' })).toBeDisabled()
await expect(page.getByRole('button', { name: 'Deploy' })).toBeDisabled()
await expect(page.getByText('Mode lecture/edition: validate/deploy requierent les APIs Story V2.')).toBeVisible()
await expect(page.getByRole('button', { name: 'Valider' })).toBeDisabled()
await expect(page.getByRole('button', { name: 'Déployer' })).toBeDisabled()
})
@@ -0,0 +1,41 @@
import { expect, test } from '@playwright/test'
test('@mock story designer imports yaml, edits bindings and supports undo/redo', async ({ page }) => {
await page.addInitScript(() => window.localStorage.clear())
await page.route('**/api/story/list', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ scenarios: [{ id: 'DEFAULT', estimated_duration_s: 120 }] }),
})
})
await page.goto('/')
await page.getByRole('button', { name: 'Designer' }).click()
await expect(page.getByText('Nodes: 2')).toBeVisible()
await page.getByRole('button', { name: 'Ajouter node' }).click()
await expect(page.getByText('Nodes: 3')).toBeVisible()
await page.getByRole('button', { name: 'Import YAML → Graphe' }).click()
await expect(page.getByText('YAML importé dans le graphe.')).toBeVisible()
await expect(page.getByText('Nodes: 2')).toBeVisible()
await page.getByRole('button', { name: 'Ajouter node' }).click()
await expect(page.getByText('Nodes: 3')).toBeVisible()
await page.getByRole('button', { name: 'Annuler' }).click()
await expect(page.getByText('Nodes: 2')).toBeVisible()
await page.getByRole('button', { name: 'Retablir' }).click()
await expect(page.getByText('Nodes: 3')).toBeVisible()
const firstBindingIdInput = page.locator('label:has-text("ID") input').first()
await firstBindingIdInput.fill('APP_TEST_BIND')
await page.getByRole('button', { name: 'Export Graphe → YAML' }).click()
await firstBindingIdInput.fill('APP_TEMP_BIND')
await page.getByRole('button', { name: 'Import YAML → Graphe' }).click()
await expect(firstBindingIdInput).toHaveValue('APP_TEST_BIND')
})
@@ -42,10 +42,10 @@ test('@mock detects story_v2 and opens orchestrator', async ({ page }) => {
await page.goto('/')
await expect(page.getByText('Story V2 API')).toBeVisible()
await expect(page.getByRole('button', { name: 'Play' }).first()).toBeVisible()
await page.getByRole('button', { name: 'Play' }).first().click()
await expect(page.getByRole('heading', { name: 'Live Orchestrator' })).toBeVisible()
await expect(page.getByText('API Story V2')).toBeVisible()
await expect(page.getByRole('button', { name: 'Lancer' }).first()).toBeVisible()
await page.getByRole('button', { name: 'Lancer' }).first().click()
await expect(page.getByRole('heading', { name: 'Orchestrateur live' })).toBeVisible()
})
test('@mock maps API 404 and 507 errors to friendly messages', async ({ page }) => {
@@ -77,11 +77,10 @@ test('@mock maps API 404 and 507 errors to friendly messages', async ({ page })
await page.goto('/')
await page.getByRole('button', { name: 'Play' }).first().click()
await expect(page.getByText('Scenario not found. Browse available scenarios.')).toBeVisible()
await page.getByRole('button', { name: 'Lancer' }).first().click()
await expect(page.getByText('Scenario introuvable. Verifie la liste disponible.')).toBeVisible()
await page.getByRole('button', { name: 'Story Designer' }).click()
await page.getByRole('button', { name: 'Deploy' }).click()
await expect(page.getByText('Device storage full. Delete old scenarios.')).toBeVisible()
await page.getByRole('button', { name: 'Designer' }).click()
await page.getByRole('button', { name: 'Déployer' }).click()
await expect(page.getByText("Stockage du device plein. Supprime d'anciens scenarios.")).toBeVisible()
})
@@ -0,0 +1,195 @@
import { describe, expect, it } from 'vitest'
import {
generateStoryYamlFromGraph,
importStoryYamlToGraph,
type StoryGraphDocument,
validateStoryGraph,
} from '../../src/features/story-designer'
const canonicalYaml = `id: STORY_CANON
version: 2
initial_step: STEP_A
app_bindings:
- id: APP_LA
app: LA_DETECTOR
config:
hold_ms: 5000
unlock_event: UNLOCK
require_listening: true
- id: APP_AUDIO
app: AUDIO_PACK
steps:
- step_id: STEP_A
screen_scene_id: SCENE_A
audio_pack_id: PACK_A
actions: [ACTION_TRACE_STEP]
apps: [APP_LA]
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: unlock
event_name: UNLOCK
target_step_id: STEP_B
after_ms: 0
priority: 100
- step_id: STEP_B
screen_scene_id: SCENE_B
audio_pack_id: ""
actions: [ACTION_TRACE_STEP]
apps: [APP_AUDIO]
mp3_gate_open: true
transitions: []
`
const normalizeDocument = (document: StoryGraphDocument) => ({
scenarioId: document.scenarioId,
version: document.version,
initialStep: document.initialStep,
appBindings: [...document.appBindings]
.map((binding) => ({
id: binding.id,
app: binding.app,
config: binding.config
? {
hold_ms: binding.config.hold_ms,
unlock_event: binding.config.unlock_event,
require_listening: binding.config.require_listening,
}
: undefined,
}))
.sort((left, right) => left.id.localeCompare(right.id)),
nodes: [...document.nodes]
.map((node) => ({
stepId: node.stepId,
screenSceneId: node.screenSceneId,
audioPackId: node.audioPackId,
actions: [...node.actions],
apps: [...node.apps].sort(),
mp3GateOpen: node.mp3GateOpen,
isInitial: node.isInitial,
}))
.sort((left, right) => left.stepId.localeCompare(right.stepId)),
edges: [...document.edges]
.map((edge) => ({
fromNodeId: edge.fromNodeId,
toNodeId: edge.toNodeId,
trigger: edge.trigger,
eventType: edge.eventType,
eventName: edge.eventName,
afterMs: edge.afterMs,
priority: edge.priority,
}))
.sort((left, right) => `${left.fromNodeId}-${left.toNodeId}`.localeCompare(`${right.fromNodeId}-${right.toNodeId}`)),
})
describe('storyYaml import/export', () => {
it('importe un YAML Story V2 canonique', () => {
const result = importStoryYamlToGraph(canonicalYaml)
expect(result.errors).toEqual([])
expect(result.document).toBeDefined()
expect(result.document?.nodes).toHaveLength(2)
expect(result.document?.edges).toHaveLength(1)
expect(result.document?.appBindings).toHaveLength(2)
expect(result.document?.initialStep).toBe('STEP_A')
expect(result.document?.edges[0]?.eventType).toBe('unlock')
})
it('importe un YAML legacy simplifie et mappe les transitions', () => {
const legacyYaml = `id: LEGACY
version: 2
initial_step_id: STEP_WAIT_UNLOCK
steps:
- id: STEP_WAIT_UNLOCK
screen_scene_id: SCENE_LOCKED
transitions:
- event: unlock
target: STEP_DONE
- id: STEP_DONE
screen_scene_id: SCENE_DONE
transitions: []
`
const result = importStoryYamlToGraph(legacyYaml)
expect(result.errors).toEqual([])
expect(result.warnings.length).toBeGreaterThan(0)
expect(result.document?.nodes).toHaveLength(2)
expect(result.document?.edges).toHaveLength(1)
expect(result.document?.edges[0]).toMatchObject({
trigger: 'on_event',
eventType: 'unlock',
eventName: 'UNLOCK',
afterMs: 0,
priority: 100,
})
})
it('retourne une erreur claire si le YAML est invalide', () => {
const result = importStoryYamlToGraph('id: BAD\nsteps: [')
expect(result.document).toBeUndefined()
expect(result.errors.length).toBeGreaterThan(0)
})
it('clamp les transitions invalides (after_ms et priority)', () => {
const yamlWithInvalidTransition = `id: CLAMP_TEST
version: 2
initial_step: STEP_A
steps:
- step_id: STEP_A
transitions:
- trigger: after_ms
event_type: timer
event_name: TIMER_A
target_step_id: STEP_B
after_ms: -42
priority: 999
- step_id: STEP_B
transitions: []
`
const result = importStoryYamlToGraph(yamlWithInvalidTransition)
expect(result.document).toBeDefined()
expect(result.document?.edges[0]?.afterMs).toBe(0)
expect(result.document?.edges[0]?.priority).toBe(255)
})
it('maintient une structure stable en round-trip import -> export -> import', () => {
const imported = importStoryYamlToGraph(canonicalYaml)
expect(imported.document).toBeDefined()
const exportedYaml = generateStoryYamlFromGraph(imported.document as StoryGraphDocument)
const reimported = importStoryYamlToGraph(exportedYaml)
expect(reimported.errors).toEqual([])
expect(reimported.document).toBeDefined()
expect(normalizeDocument(reimported.document as StoryGraphDocument)).toEqual(
normalizeDocument(imported.document as StoryGraphDocument),
)
})
})
describe('validation', () => {
it('signale les bindings dupliques et priorites invalides', () => {
const base = importStoryYamlToGraph(canonicalYaml).document as StoryGraphDocument
const document: StoryGraphDocument = {
...base,
appBindings: [
{ id: 'APP_AUDIO', app: 'AUDIO_PACK' },
{ id: 'APP_AUDIO', app: 'AUDIO_PACK' },
],
edges: [
{
...base.edges[0],
priority: 999,
},
],
}
const result = validateStoryGraph(document)
expect(result.errors.some((entry) => entry.includes('app_binding duplique'))).toBe(true)
expect(result.errors.some((entry) => entry.includes('priority invalide'))).toBe(true)
})
})
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['tests/unit/**/*.test.ts'],
environment: 'node',
passWithNoTests: false,
},
})