diff --git a/pony-frontend/src/App.test.js b/pony-frontend/src/App.test.js index 32ab8e4..843d568 100644 --- a/pony-frontend/src/App.test.js +++ b/pony-frontend/src/App.test.js @@ -58,7 +58,7 @@ jest.mock('./components/SpeakButton', () => function SpeakButton() { return null jest.mock('./components/VoiceButton', () => function VoiceButton() { return null; }); import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import App from './App'; import * as SFX from './SceneMusic'; @@ -127,12 +127,13 @@ function mockMultiFetch(responses) { const findByText = (t) => screen.getByText(t); // Clicking a pony type on the configurator's first step only selects the -// type and advances to the body-color step; five more "Næste" clicks walk -// through eyes/mane/tail/horn/wings to the final step, whose "Start eventyr" -// button actually starts the game. +// type and advances to the body-color step. How many "Næste" clicks it +// takes to reach the end depends on the type — a Jordpony has neither +// horn nor wings steps, so its wizard is shorter than an Alicorn's — so +// just click "Næste" until "Start eventyr" appears instead of a fixed count. async function pickPonyAndStartGame(name = 'Jordpony') { await userEvent.click(screen.getByText(name)); - for (let i = 0; i < 5; i++) { + while (screen.queryByRole('button', { name: 'Næste trin' })) { await userEvent.click(screen.getByRole('button', { name: 'Næste trin' })); } await userEvent.click(screen.getByRole('button', { name: 'Start eventyr' })); @@ -332,6 +333,10 @@ test('shows four story choices instead of dice in a choice scene', async () => { const choices = screen.getAllByRole('button', { name: /^Vælg / }); expect(choices).toHaveLength(4); expect(choices[0].closest('.game-controls')).toHaveClass('game-controls-options'); + act(() => window.dispatchEvent(new CustomEvent('pony-narration-status', { + detail: { status: 'preparing' }, + }))); + choices.forEach(choice => expect(choice).toBeEnabled()); expect(screen.queryByRole('button', { name: 'Kast terningerne' })).not.toBeInTheDocument(); }); diff --git a/pony-frontend/src/pages/PixelPonyConfiguratorPage.js b/pony-frontend/src/pages/PixelPonyConfiguratorPage.js index ce64a9c..6fc6de4 100644 --- a/pony-frontend/src/pages/PixelPonyConfiguratorPage.js +++ b/pony-frontend/src/pages/PixelPonyConfiguratorPage.js @@ -1,10 +1,11 @@ /** * Pixel Pony Configurator — build your pony in steps: type, body color, - * eyes, mane, tail, horn, wings. Replaces the old plain pony-type select - * screen; finishing the wizard starts the game with the chosen type. + * eyes, mane, tail, and (only if the chosen type actually has them) horn + * and/or wings. Replaces the old plain pony-type select screen; finishing + * the wizard starts the game with the chosen type. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { motion } from 'framer-motion'; import SceneMusic from '../SceneMusic'; import VolumeControl from '../components/VolumeControl'; @@ -18,7 +19,6 @@ import { } from '../pixelPony/spriteData'; import { loadAppearance, saveAppearance } from '../services/ponyAppearance'; -const STEPS = ['type', 'body', 'eyes', 'mane', 'tail', 'horn', 'wings']; const STEP_TITLES = { type: 'Vælg din Pony! 🐴', body: 'Vælg krop-farve 🎨', @@ -54,6 +54,18 @@ export default function PixelPonyConfiguratorPage({ ponies, onSelectType, volume const [appearance, setAppearance] = useState(loadAppearance); const [bobFrame, setBobFrame] = useState(false); + const selectedType = typeIdx !== null ? (PONY_TYPES[typeIdx] || PONY_TYPES[0]) : null; + + // Horn/wings only appear as steps — and only exist at all — if the + // chosen pony type actually has them. A Jordpony has neither, so it + // never gets the chance to pick a horn or wings. + const steps = useMemo(() => { + const base = ['type', 'body', 'eyes', 'mane', 'tail']; + if (selectedType?.hasHorn) base.push('horn'); + if (selectedType?.hasWings) base.push('wings'); + return base; + }, [selectedType]); + useEffect(() => { const id = window.setInterval(() => setBobFrame(b => !b), 500); return () => window.clearInterval(id); @@ -76,23 +88,25 @@ export default function PixelPonyConfiguratorPage({ ponies, onSelectType, volume } }; - const goNext = () => setStep(s => Math.min(s + 1, STEPS.length - 1)); + const goNext = () => setStep(s => Math.min(s + 1, steps.length - 1)); const handleStart = () => { saveAppearance(appearance); onSelectType(typeIdx ?? 0); }; - const stepName = STEPS[step]; - const isLastStep = step === STEPS.length - 1; + const stepName = steps[step]; + const isLastStep = step === steps.length - 1; const narration = { type: 'Vælg din pony type. Tryk på den pony du vil være.', body: 'Vælg en farve til din ponys krop.', eyes: 'Vælg en farve til din ponys øjne.', mane: 'Vælg en manke og en mankefarve til din pony.', - tail: 'Vælg en hale og en halefarve til din pony.', - horn: 'Vælg om din pony skal have horn, og vælg form og farve.', - wings: 'Vælg om din pony skal have vinger, og vælg form og farve. Tryk på start eventyr når du er klar.', + tail: isLastStep + ? 'Vælg en hale og en halefarve til din pony. Tryk på start eventyr når du er klar.' + : 'Vælg en hale og en halefarve til din pony.', + horn: 'Vælg form og farve på hornet.', + wings: 'Vælg form og farve på vingerne. Tryk på start eventyr når du er klar.', }[stepName]; return ( @@ -114,7 +128,7 @@ export default function PixelPonyConfiguratorPage({ ponies, onSelectType, volume {STEP_TITLES[stepName]} -

Trin {step + 1} af {STEPS.length}

+

Trin {step + 1} af {steps.length}

{stepName !== 'type' && ( @@ -248,100 +262,68 @@ export default function PixelPonyConfiguratorPage({ ponies, onSelectType, volume {stepName === 'horn' && ( <>
-
- +

Horn-form

+
+ {HORN_STYLES.map(h => ( + + ))}
- {appearance.hasHorn && ( - <> -
-

Horn-form

-
- {HORN_STYLES.map(h => ( - - ))} -
-
-
-

Hornfarve

- set('hornColor', id)} - labelPrefix="Hornfarve" - swatchColor="#ffe066" - /> -
- - )} +
+

Hornfarve

+ set('hornColor', id)} + labelPrefix="Hornfarve" + swatchColor="#ffe066" + /> +
)} {stepName === 'wings' && ( <>
-
- +

Vinge-form

+
+ {WING_STYLES.map(w => ( + + ))}
- {appearance.hasWings && ( - <> -
-

Vinge-form

-
- {WING_STYLES.map(w => ( - - ))} -
-
-
-

Vingefarve

- set('wingColor', id)} - labelPrefix="Vingefarve" - swatchColor="#f5c8af" - /> -
- - )} +
+

Vingefarve

+ set('wingColor', id)} + labelPrefix="Vingefarve" + swatchColor="#f5c8af" + /> +
)} @@ -354,7 +336,7 @@ export default function PixelPonyConfiguratorPage({ ponies, onSelectType, volume Næste ➡️ )} - {isLastStep && ( + {isLastStep && stepName !== 'type' && ( Start eventyr! 🎮 diff --git a/pony-frontend/src/pages/PixelPonyConfiguratorPage.test.js b/pony-frontend/src/pages/PixelPonyConfiguratorPage.test.js index 6daff58..db60b54 100644 --- a/pony-frontend/src/pages/PixelPonyConfiguratorPage.test.js +++ b/pony-frontend/src/pages/PixelPonyConfiguratorPage.test.js @@ -16,6 +16,8 @@ jest.mock('framer-motion', () => { jest.mock('../SceneMusic', () => function SceneMusic() { return null; }); jest.mock('../services/tts', () => ({ speakDanish: jest.fn(), cancelSpeech: jest.fn() })); +// Order matches spriteData.PONY_TYPES: jordpony (neither), pegasus (wings +// only), enhjorning (horn only), alicorn (both). const PONIES = [ { navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪' }, { navn: 'Pegasus', emoji: '🦅', bonus: 'Flyver 🪽' }, @@ -23,9 +25,6 @@ const PONIES = [ { navn: 'Alicorn', emoji: '👑', bonus: 'Magi + vinger 🌟' }, ]; -// type -> body -> eyes -> mane -> tail -> horn -> wings -const STEP_COUNT = 7; - beforeEach(() => { window.localStorage.clear(); }); @@ -45,15 +44,12 @@ function renderWizard(onSelectType = jest.fn()) { } const next = () => userEvent.click(screen.getByRole('button', { name: 'Næste trin' })); -const pickJordpony = () => userEvent.click(screen.getByRole('button', { name: 'Vælg Jordpony - Stærk 💪' })); +const pick = (label) => userEvent.click(screen.getByRole('button', { name: label })); -async function pickTypeAndAdvanceToWings() { - await pickJordpony(); - await next(); // body -> eyes - await next(); // eyes -> mane - await next(); // mane -> tail - await next(); // tail -> horn - await next(); // horn -> wings +async function clickThroughToEnd() { + while (screen.queryByRole('button', { name: 'Næste trin' })) { + await next(); + } } test('step 1 shows all four pony types', () => { @@ -62,13 +58,13 @@ test('step 1 shows all four pony types', () => { expect(screen.getByText('Pegasus')).toBeInTheDocument(); expect(screen.getByText('Enhjørning')).toBeInTheDocument(); expect(screen.getByText('Alicorn')).toBeInTheDocument(); - expect(screen.getByText(`Trin 1 af ${STEP_COUNT}`)).toBeInTheDocument(); + expect(screen.getByText('Trin 1 af 5')).toBeInTheDocument(); }); test('picking a pony type advances to the body-color step, not mane', async () => { renderWizard(); - await userEvent.click(screen.getByRole('button', { name: 'Vælg Pegasus - Flyver 🪽' })); - expect(screen.getByText(`Trin 2 af ${STEP_COUNT}`)).toBeInTheDocument(); + await pick('Vælg Pegasus - Flyver 🪽'); + expect(screen.getByText('Trin 2 af 6')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Kropsfarve: Lilla' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Vælg manke: Boglig' })).not.toBeInTheDocument(); }); @@ -76,106 +72,76 @@ test('picking a pony type advances to the body-color step, not mane', async () = test('mane list excludes the eye-icon files misfiled as mane styles', () => { renderWizard(); expect(screen.queryByText('Dramatisk')).not.toBeInTheDocument(); - expect(screen.queryByText('Fabelagtig')).not.toBeInTheDocument(); - expect(screen.queryByText('Nysgerrig')).not.toBeInTheDocument(); - expect(screen.queryByText('Klog')).not.toBeInTheDocument(); - expect(screen.queryByText('Kæk')).not.toBeInTheDocument(); - expect(screen.queryByText('Praktisk')).not.toBeInTheDocument(); expect(screen.queryByText('Tuf')).not.toBeInTheDocument(); }); -test('mane step shows only mane swatches', async () => { - renderWizard(); - await pickJordpony(); - await next(); // body -> eyes - await next(); // eyes -> mane - expect(screen.getByText(`Trin 4 af ${STEP_COUNT}`)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Vælg manke: Boglig' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Kropsfarve: Lilla' })).not.toBeInTheDocument(); -}); - -test('tail step offers multiple tail shapes with their own color', async () => { - renderWizard(); - await pickJordpony(); +test('Jordpony has no horn or wings step — the wizard is 5 steps and ends after tail', async () => { + const { onSelectType } = renderWizard(); + await pick('Vælg Jordpony - Stærk 💪'); + expect(screen.getByText('Trin 2 af 5')).toBeInTheDocument(); await next(); // body -> eyes await next(); // eyes -> mane await next(); // mane -> tail - expect(screen.getByText(`Trin 5 af ${STEP_COUNT}`)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Vælg hale: Lang' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Vælg hale: Kort' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Vælg hale: Krøllet' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Halefarve: Grøn' })).toBeInTheDocument(); + expect(screen.getByText('Trin 5 af 5')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Næste trin' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Start eventyr' })).toBeInTheDocument(); + await pick('Start eventyr'); + const saved = JSON.parse(window.localStorage.getItem('pony_appearance')); + expect(saved.hasHorn).toBe(false); + expect(saved.hasWings).toBe(false); + expect(onSelectType).toHaveBeenCalledWith(0); }); -test('horn step is off by default and hides style/color pickers until toggled on', async () => { +test('Pegasus gets a wings step but no horn step', async () => { renderWizard(); - await pickJordpony(); + await pick('Vælg Pegasus - Flyver 🪽'); + expect(screen.getByText('Trin 2 af 6')).toBeInTheDocument(); + await clickThroughToEnd(); + expect(screen.getByText('Vælg vinger 🪽')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Vælg vinge: Foldet' })).toBeInTheDocument(); +}); + +test('Enhjørning gets a horn step but no wings step', async () => { + renderWizard(); + await pick('Vælg Enhjørning - Magisk horn ✨'); + expect(screen.getByText('Trin 2 af 6')).toBeInTheDocument(); + await clickThroughToEnd(); + expect(screen.getByText('Vælg horn 🦄')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Vælg horn: Spids' })).toBeInTheDocument(); +}); + +test('Alicorn gets both a horn and a wings step', async () => { + const { onSelectType } = renderWizard(); + await pick('Vælg Alicorn - Magi + vinger 🌟'); + expect(screen.getByText('Trin 2 af 7')).toBeInTheDocument(); await next(); // body -> eyes await next(); // eyes -> mane await next(); // mane -> tail await next(); // tail -> horn - expect(screen.getByText(`Trin 6 af ${STEP_COUNT}`)).toBeInTheDocument(); - const toggle = screen.getByRole('button', { name: '🦄 Horn til/fra' }); - expect(toggle).toHaveAttribute('aria-pressed', 'false'); - expect(screen.queryByRole('button', { name: 'Vælg horn: Spids' })).not.toBeInTheDocument(); - await userEvent.click(toggle); - expect(screen.getByRole('button', { name: 'Vælg horn: Spids' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Vælg horn: Snoet' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Vælg horn: Lille' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Hornfarve: Gul' })).toBeInTheDocument(); -}); - -test('wings step is off by default and hides style/color pickers until toggled on', async () => { - renderWizard(); - await pickTypeAndAdvanceToWings(); - expect(screen.getByText(`Trin 7 af ${STEP_COUNT}`)).toBeInTheDocument(); - const toggle = screen.getByRole('button', { name: '🪽 Vinger til/fra' }); - expect(toggle).toHaveAttribute('aria-pressed', 'false'); - expect(screen.queryByRole('button', { name: 'Vælg vinge: Foldet' })).not.toBeInTheDocument(); - await userEvent.click(toggle); - expect(screen.getByRole('button', { name: 'Vælg vinge: Foldet' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Vælg vinge: Udspredt' })).toBeInTheDocument(); + expect(screen.getByText('Vælg horn 🦄')).toBeInTheDocument(); + await pick('Vælg horn: Snoet'); + await next(); // horn -> wings + expect(screen.getByText('Vælg vinger 🪽')).toBeInTheDocument(); + await pick('Vælg vinge: Udspredt'); + await pick('Start eventyr'); + const saved = JSON.parse(window.localStorage.getItem('pony_appearance')); + expect(saved.hasHorn).toBe(true); + expect(saved.horn).toBe('swirl'); + expect(saved.hasWings).toBe(true); + expect(saved.wing).toBe('spread'); + expect(onSelectType).toHaveBeenCalledWith(3); }); test('back on the first step exits to home', async () => { const { onNavigate } = renderWizard(); - await userEvent.click(screen.getByRole('button', { name: 'Tilbage' })); + await pick('Tilbage'); expect(onNavigate).toHaveBeenCalledWith('home'); }); test('back on a later step returns to the previous step, not home', async () => { const { onNavigate } = renderWizard(); - await pickJordpony(); - await userEvent.click(screen.getByRole('button', { name: 'Tilbage' })); + await pick('Vælg Jordpony - Stærk 💪'); + await pick('Tilbage'); expect(onNavigate).not.toHaveBeenCalled(); - expect(screen.getByText(`Trin 1 af ${STEP_COUNT}`)).toBeInTheDocument(); -}); - -test('walks through every step before starting the game', async () => { - const { onSelectType } = renderWizard(); - await pickTypeAndAdvanceToWings(); - await userEvent.click(screen.getByRole('button', { name: 'Start eventyr' })); - expect(onSelectType).toHaveBeenCalledWith(0); -}); - -test('starting the game saves the full appearance, including chosen horn/wing styles', async () => { - renderWizard(); - await pickJordpony(); - await next(); // body -> eyes - await next(); // eyes -> mane - await next(); // mane -> tail - await userEvent.click(screen.getByRole('button', { name: 'Vælg hale: Krøllet' })); - await next(); // tail -> horn - await userEvent.click(screen.getByRole('button', { name: '🦄 Horn til/fra' })); - await userEvent.click(screen.getByRole('button', { name: 'Vælg horn: Snoet' })); - await next(); // horn -> wings - await userEvent.click(screen.getByRole('button', { name: '🪽 Vinger til/fra' })); - await userEvent.click(screen.getByRole('button', { name: 'Vælg vinge: Udspredt' })); - await userEvent.click(screen.getByRole('button', { name: 'Start eventyr' })); - const saved = JSON.parse(window.localStorage.getItem('pony_appearance')); - expect(saved.tail).toBe('curly'); - expect(saved.hasHorn).toBe(true); - expect(saved.horn).toBe('swirl'); - expect(saved.hasWings).toBe(true); - expect(saved.wing).toBe('spread'); + expect(screen.getByText('Trin 1 af 5')).toBeInTheDocument(); });