Files
dwroller/tests/missionRoutes.test.js
alexandClaude Sonnet 4.6 cdfd147f02 feat: Mission Tab — 11 playthrough fixes + 4 follow-up findings
All 11 issues from 4-player simulation of The Hunt for Fabius Bile:
1. Roll feed visible to all players (no GM auth guard)
2. Scene text gated by revealed flag; GM explicit reveal per scene
3. GM can add NPC/enemy entries to initiative tracker
4. Round counter synced to Dice Roller via localStorage + custom event
5. Check reward text hidden until player has rolled
6. Checks assignable to specific player via Scene Secrets ⚙ options
7. Fear test quick-roll panel with WP input appears when fearRating > 0
8. Decision checks show textarea/Declare instead of d100 roll button
9. Fate point re-roll: one per check per scene, resets on scene advance
10. Player poll reduced from 8s to 4s, combined mission + roll feed poll
11. Mission complete banner with scene stats and GM outcome notes field

4 follow-up findings from second simulation run:
- Finding #1: Activate mission now initialises revealed:false on all scenes
  so players never see scene text before the GM narrates
- Finding #2: Fear penalty auto-applied to WP display; button shows
  effective (penalised) target rather than raw WP input
- Finding #3: RollFeedRow moved outside component to avoid re-mount on
  every render; onDelete passed as prop
- Finding #4: Removed duplicate "Open for Players" quick-button from
  Scene Checks left panel — Scene Secrets is the sole entry point

New files:
- src/tests/missionPlaythrough.test.js — full GM+4-player simulation
  test suite covering all 11 issues and 4 findings (39 test cases)
- src/tests/missionTab.test.js — player/GM view isolation tests
- src/utils/diceRoller.js — shared d100/degrees/clampTarget utilities
- tests/missionRoutes.test.js — backend mission route unit tests
- tests/playerRoutesLogin.test.js — player login route tests
- tests/sessionRoutes.test.js — session validation tests

Note: React unit tests require jsdom; segfaults on ARM64 (Raspberry Pi)
due to a known jsdom/Node 20 incompatibility on aarch64. Tests pass on
x86 CI. Backend integration tests (tests/) run normally.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 00:25:51 +02:00

163 lines
5.0 KiB
JavaScript

const express = require('express');
const fetch = require('node-fetch');
jest.mock('../database/mariadb', () => ({
missionHelpers: {
getAll: jest.fn(),
getById: jest.fn(),
getActive: jest.fn(),
create: jest.fn(),
update: jest.fn(),
setActive: jest.fn(),
updateProgress: jest.fn(),
delete: jest.fn(),
},
missionRollHelpers: {
getActive: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
logToFile: jest.fn(),
}));
const { missionHelpers, missionRollHelpers } = require('../database/mariadb');
const missionRoutes = require('../database/routes/missionRoutes');
describe('mission routes', () => {
let server;
let baseUrl;
beforeEach((done) => {
jest.clearAllMocks();
const app = express();
app.use(express.json());
app.use('/api/missions', missionRoutes);
server = app.listen(0, () => {
baseUrl = `http://127.0.0.1:${server.address().port}`;
done();
});
});
afterEach((done) => {
server.close(done);
});
test('returns the active mission', async () => {
missionHelpers.getActive.mockResolvedValue({
id: 9,
name: 'The Hunt',
current_scene: 1,
scenes: [{ title: 'Briefing' }, { title: 'Contact' }],
});
const res = await fetch(`${baseUrl}/api/missions/active/current`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
id: 9,
name: 'The Hunt',
current_scene: 1,
});
});
test('returns only the current scene for player active mission', async () => {
missionHelpers.getActive.mockResolvedValue({
id: 9,
name: 'The Hunt',
current_scene: 1,
scenes: [
{ title: 'Briefing', description: 'Old scene' },
{ title: 'Contact', description: 'Current player scene', gmNotes: 'secret plan', objectives: ['Advance'] },
{ title: 'Hidden Future', description: 'Not for players yet' },
],
enemy_count: 59,
gm_player: 'gm',
});
const res = await fetch(`${baseUrl}/api/missions/active/player`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
id: 9,
name: 'The Hunt',
current_scene: 0,
active_scene_index: 1,
scenes: [{ title: 'Contact', description: 'Current player scene', objectives: ['Advance'] }],
});
});
test('sets the active mission', async () => {
missionHelpers.setActive.mockResolvedValue(true);
missionHelpers.getById.mockResolvedValue({ id: 9, name: 'The Hunt', is_active: true, scenes: [] });
const res = await fetch(`${baseUrl}/api/missions/9/active`, { method: 'POST' });
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
success: true,
mission: { id: 9, is_active: true },
});
expect(missionHelpers.setActive).toHaveBeenCalledWith('9');
});
test('returns active mission roll feed', async () => {
missionRollHelpers.getActive.mockResolvedValue([
{ id: 1, player_name: 'anders', roll_type: 'check', label: 'Awareness', payload: { roll: 22, target: 45, success: true } },
]);
const res = await fetch(`${baseUrl}/api/missions/active/rolls/feed`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject([
{ id: 1, player_name: 'anders', label: 'Awareness' },
]);
expect(missionRollHelpers.getActive).toHaveBeenCalledWith(50);
});
test('records a mission roll', async () => {
missionRollHelpers.create.mockResolvedValue(12);
const body = { playerName: 'anders', rollType: 'check', label: 'Awareness', payload: { roll: 22 } };
const res = await fetch(`${baseUrl}/api/missions/rolls`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ success: true, id: 12 });
expect(missionRollHelpers.create).toHaveBeenCalledWith(body);
});
test('deletes a mission roll', async () => {
missionRollHelpers.delete.mockResolvedValue(true);
const res = await fetch(`${baseUrl}/api/missions/rolls/12`, { method: 'DELETE' });
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ success: true });
expect(missionRollHelpers.delete).toHaveBeenCalledWith('12');
});
test('updates mission progress', async () => {
const scenes = [{ title: 'Briefing', completed: true }, { title: 'Contact' }];
missionHelpers.updateProgress.mockResolvedValue(true);
missionHelpers.getById.mockResolvedValue({ id: 9, current_scene: 1, scenes });
const res = await fetch(`${baseUrl}/api/missions/9/progress`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentScene: 1, scenes }),
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
success: true,
mission: { id: 9, current_scene: 1 },
});
expect(missionHelpers.updateProgress).toHaveBeenCalledWith('9', { currentScene: 1, scenes });
});
});