fix: gate horn/wings steps by pony type — a Jordpony can no longer pick them

Horn/wings choices were free-standing toggles independent of the chosen
pony type, so e.g. a Jordpony (which has neither in the game's own lore/
stats) could still be given a horn or wings in the configurator.

Wizard steps are now computed from the selected type's hasHorn/hasWings
flags: Jordpony gets 5 steps (type/body/eyes/mane/tail, no horn or wings
step at all), Pegasus 6 (+wings), Enhjørning 6 (+horn), Alicorn 7 (+both).
appearance.hasHorn/hasWings are set directly from the type on pick and are
no longer separately toggleable — the on/off buttons are gone since the
type alone decides whether they exist.

App.test.js's pickPonyAndStartGame helper now clicks "Næste" until "Start
eventyr" appears rather than a fixed count, since step count depends on
which pony type the test picks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 12:29:45 +00:00
parent 3502350d4e
commit a30179737f
3 changed files with 147 additions and 194 deletions

View File

@@ -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();
});

View File

@@ -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
<motion.h1 initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="title">
{STEP_TITLES[stepName]}
</motion.h1>
<p className="pixel-configurator-progress">Trin {step + 1} af {STEPS.length}</p>
<p className="pixel-configurator-progress">Trin {step + 1} af {steps.length}</p>
<SpeakButton text={narration} volume={volume} label="Læs siden højt" />
{stepName !== 'type' && (
@@ -248,100 +262,68 @@ export default function PixelPonyConfiguratorPage({ ponies, onSelectType, volume
{stepName === 'horn' && (
<>
<section className="pixel-configurator-section">
<div className="pixel-toggle-row">
<button
type="button"
className={`pixel-toggle ${appearance.hasHorn ? 'is-selected' : ''}`}
onClick={() => set('hasHorn', !appearance.hasHorn)}
aria-pressed={appearance.hasHorn}
>
🦄 Horn til/fra
</button>
<h2>Horn-form</h2>
<div className="pixel-mane-grid">
{HORN_STYLES.map(h => (
<button
key={h.id}
type="button"
className={`pixel-mane-swatch ${appearance.horn === h.id ? 'is-selected' : ''}`}
onClick={() => set('horn', h.id)}
aria-label={`Vælg horn: ${h.label}`}
aria-pressed={appearance.horn === h.id}
title={h.label}
>
<HornIcon horn={h.id} hornColor={appearance.hornColor} scale={2} />
<span>{h.label}</span>
</button>
))}
</div>
</section>
{appearance.hasHorn && (
<>
<section className="pixel-configurator-section">
<h2>Horn-form</h2>
<div className="pixel-mane-grid">
{HORN_STYLES.map(h => (
<button
key={h.id}
type="button"
className={`pixel-mane-swatch ${appearance.horn === h.id ? 'is-selected' : ''}`}
onClick={() => set('horn', h.id)}
aria-label={`Vælg horn: ${h.label}`}
aria-pressed={appearance.horn === h.id}
title={h.label}
>
<HornIcon horn={h.id} hornColor={appearance.hornColor} scale={2} />
<span>{h.label}</span>
</button>
))}
</div>
</section>
<section className="pixel-configurator-section">
<h2>Hornfarve</h2>
<ColorSwatches
options={COLOR_OPTIONS}
value={appearance.hornColor}
onPick={(id) => set('hornColor', id)}
labelPrefix="Hornfarve"
swatchColor="#ffe066"
/>
</section>
</>
)}
<section className="pixel-configurator-section">
<h2>Hornfarve</h2>
<ColorSwatches
options={COLOR_OPTIONS}
value={appearance.hornColor}
onPick={(id) => set('hornColor', id)}
labelPrefix="Hornfarve"
swatchColor="#ffe066"
/>
</section>
</>
)}
{stepName === 'wings' && (
<>
<section className="pixel-configurator-section">
<div className="pixel-toggle-row">
<button
type="button"
className={`pixel-toggle ${appearance.hasWings ? 'is-selected' : ''}`}
onClick={() => set('hasWings', !appearance.hasWings)}
aria-pressed={appearance.hasWings}
>
🪽 Vinger til/fra
</button>
<h2>Vinge-form</h2>
<div className="pixel-mane-grid">
{WING_STYLES.map(w => (
<button
key={w.id}
type="button"
className={`pixel-mane-swatch ${appearance.wing === w.id ? 'is-selected' : ''}`}
onClick={() => set('wing', w.id)}
aria-label={`Vælg vinge: ${w.label}`}
aria-pressed={appearance.wing === w.id}
title={w.label}
>
<WingIcon wing={w.id} wingColor={appearance.wingColor} scale={2} />
<span>{w.label}</span>
</button>
))}
</div>
</section>
{appearance.hasWings && (
<>
<section className="pixel-configurator-section">
<h2>Vinge-form</h2>
<div className="pixel-mane-grid">
{WING_STYLES.map(w => (
<button
key={w.id}
type="button"
className={`pixel-mane-swatch ${appearance.wing === w.id ? 'is-selected' : ''}`}
onClick={() => set('wing', w.id)}
aria-label={`Vælg vinge: ${w.label}`}
aria-pressed={appearance.wing === w.id}
title={w.label}
>
<WingIcon wing={w.id} wingColor={appearance.wingColor} scale={2} />
<span>{w.label}</span>
</button>
))}
</div>
</section>
<section className="pixel-configurator-section">
<h2>Vingefarve</h2>
<ColorSwatches
options={COLOR_OPTIONS}
value={appearance.wingColor}
onPick={(id) => set('wingColor', id)}
labelPrefix="Vingefarve"
swatchColor="#f5c8af"
/>
</section>
</>
)}
<section className="pixel-configurator-section">
<h2>Vingefarve</h2>
<ColorSwatches
options={COLOR_OPTIONS}
value={appearance.wingColor}
onPick={(id) => set('wingColor', id)}
labelPrefix="Vingefarve"
swatchColor="#f5c8af"
/>
</section>
</>
)}
@@ -354,7 +336,7 @@ export default function PixelPonyConfiguratorPage({ ponies, onSelectType, volume
Næste
</motion.button>
)}
{isLastStep && (
{isLastStep && stepName !== 'type' && (
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }} className="btn-start" onClick={handleStart} aria-label="Start eventyr">
Start eventyr! 🎮
</motion.button>

View File

@@ -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();
});