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]>
This commit is contained in:
@@ -100,11 +100,40 @@ const createTables = async () => {
|
||||
player_count INT NOT NULL DEFAULT 3,
|
||||
scenes JSON DEFAULT ('[]'),
|
||||
gm_player VARCHAR(255),
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 0,
|
||||
current_scene INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Shared mission roll log for GM visibility
|
||||
await connection.execute(`
|
||||
CREATE TABLE IF NOT EXISTS mission_rolls (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
mission_id INT,
|
||||
player_name VARCHAR(255),
|
||||
roll_type VARCHAR(50) NOT NULL DEFAULT 'check',
|
||||
scene_index INT NOT NULL DEFAULT 0,
|
||||
label VARCHAR(500),
|
||||
payload JSON DEFAULT ('{}'),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_mission_rolls_mission_created (mission_id, created_at)
|
||||
)
|
||||
`);
|
||||
|
||||
try {
|
||||
await connection.execute(`ALTER TABLE missions ADD COLUMN is_active TINYINT(1) NOT NULL DEFAULT 0`);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ER_DUP_FIELDNAME') throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await connection.execute(`ALTER TABLE missions ADD COLUMN current_scene INT NOT NULL DEFAULT 0`);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ER_DUP_FIELDNAME') throw error;
|
||||
}
|
||||
|
||||
await connection.execute(`CREATE INDEX IF NOT EXISTS idx_missions_created_at ON missions(created_at)`);
|
||||
await connection.execute(`CREATE INDEX IF NOT EXISTS idx_missions_theme ON missions(theme)`);
|
||||
|
||||
@@ -468,6 +497,8 @@ const missionHelpers = {
|
||||
const [rows] = await pool.execute('SELECT * FROM missions ORDER BY created_at DESC');
|
||||
return rows.map(row => ({
|
||||
...row,
|
||||
is_active: Boolean(row.is_active),
|
||||
current_scene: row.current_scene || 0,
|
||||
scenes: typeof row.scenes === 'string' ? JSON.parse(row.scenes) : row.scenes
|
||||
}));
|
||||
} catch (error) {
|
||||
@@ -483,6 +514,8 @@ const missionHelpers = {
|
||||
const row = rows[0];
|
||||
return {
|
||||
...row,
|
||||
is_active: Boolean(row.is_active),
|
||||
current_scene: row.current_scene || 0,
|
||||
scenes: typeof row.scenes === 'string' ? JSON.parse(row.scenes) : row.scenes
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -521,6 +554,64 @@ const missionHelpers = {
|
||||
}
|
||||
},
|
||||
|
||||
getActive: async () => {
|
||||
try {
|
||||
const [rows] = await pool.execute('SELECT * FROM missions WHERE is_active = 1 ORDER BY updated_at DESC LIMIT 1');
|
||||
if (rows.length === 0) return null;
|
||||
const row = rows[0];
|
||||
return {
|
||||
...row,
|
||||
is_active: Boolean(row.is_active),
|
||||
current_scene: row.current_scene || 0,
|
||||
scenes: typeof row.scenes === 'string' ? JSON.parse(row.scenes) : row.scenes
|
||||
};
|
||||
} catch (error) {
|
||||
logToFile('MariaDB: Error getting active mission', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
setActive: async (id) => {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [existing] = await connection.execute('SELECT id FROM missions WHERE id = ? LIMIT 1', [id]);
|
||||
if (existing.length === 0) {
|
||||
await connection.rollback();
|
||||
return false;
|
||||
}
|
||||
await connection.execute('UPDATE missions SET is_active = 0');
|
||||
const [result] = await connection.execute(
|
||||
'UPDATE missions SET is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
await connection.commit();
|
||||
logToFile('MariaDB: Set active mission', id);
|
||||
return result.affectedRows > 0;
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
logToFile('MariaDB: Error setting active mission', id, error);
|
||||
return false;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
},
|
||||
|
||||
updateProgress: async (id, progressData) => {
|
||||
try {
|
||||
const { currentScene, scenes } = progressData;
|
||||
const [result] = await pool.execute(
|
||||
'UPDATE missions SET current_scene = ?, scenes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[Number.isInteger(currentScene) ? currentScene : 0, JSON.stringify(scenes || []), id]
|
||||
);
|
||||
logToFile('MariaDB: Updated mission progress', id);
|
||||
return result.affectedRows > 0;
|
||||
} catch (error) {
|
||||
logToFile('MariaDB: Error updating mission progress', id, error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
delete: async (id) => {
|
||||
try {
|
||||
const [result] = await pool.execute('DELETE FROM missions WHERE id = ?', [id]);
|
||||
@@ -533,6 +624,69 @@ const missionHelpers = {
|
||||
}
|
||||
};
|
||||
|
||||
const missionRollHelpers = {
|
||||
create: async (rollData) => {
|
||||
try {
|
||||
let missionId = rollData.missionId || rollData.mission_id || null;
|
||||
let sceneIndex = Number.isInteger(rollData.sceneIndex) ? rollData.sceneIndex : Number(rollData.scene_index || 0);
|
||||
if (!missionId) {
|
||||
const active = await missionHelpers.getActive();
|
||||
missionId = active?.id || null;
|
||||
sceneIndex = active?.current_scene || sceneIndex || 0;
|
||||
}
|
||||
const payload = rollData.payload && typeof rollData.payload === 'object' ? rollData.payload : {};
|
||||
const [result] = await pool.execute(
|
||||
'INSERT INTO mission_rolls (mission_id, player_name, roll_type, scene_index, label, payload) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
missionId,
|
||||
rollData.playerName || rollData.player_name || 'unknown',
|
||||
rollData.rollType || rollData.roll_type || 'check',
|
||||
Number.isFinite(sceneIndex) ? sceneIndex : 0,
|
||||
rollData.label || payload.name || payload.weapon || null,
|
||||
JSON.stringify(payload),
|
||||
]
|
||||
);
|
||||
return result.insertId;
|
||||
} catch (error) {
|
||||
logToFile('MariaDB: Error creating mission roll', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
getForMission: async (missionId, limit = 50) => {
|
||||
try {
|
||||
const boundedLimit = Math.max(1, Math.min(Number(limit) || 50, 100));
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT * FROM mission_rolls WHERE mission_id = ? ORDER BY created_at DESC LIMIT ${boundedLimit}`,
|
||||
[missionId]
|
||||
);
|
||||
return rows.map(row => ({
|
||||
...row,
|
||||
payload: typeof row.payload === 'string' ? JSON.parse(row.payload || '{}') : row.payload,
|
||||
}));
|
||||
} catch (error) {
|
||||
logToFile('MariaDB: Error getting mission rolls', missionId, error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
getActive: async (limit = 50) => {
|
||||
const active = await missionHelpers.getActive();
|
||||
if (!active) return [];
|
||||
return missionRollHelpers.getForMission(active.id, limit);
|
||||
},
|
||||
|
||||
delete: async (id) => {
|
||||
try {
|
||||
const [result] = await pool.execute('DELETE FROM mission_rolls WHERE id = ?', [id]);
|
||||
return result.affectedRows > 0;
|
||||
} catch (error) {
|
||||
logToFile('MariaDB: Error deleting mission roll', id, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize database
|
||||
createTables().catch(error => {
|
||||
console.error('Failed to initialize MariaDB:', error);
|
||||
@@ -549,5 +703,6 @@ module.exports = {
|
||||
weaponsHelpers,
|
||||
bestiaryHelpers,
|
||||
missionHelpers,
|
||||
missionRollHelpers,
|
||||
logToFile
|
||||
};
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
const express = require('express');
|
||||
const { missionHelpers, logToFile } = require('../mariadb');
|
||||
const { missionHelpers, missionRollHelpers, logToFile } = require('../mariadb');
|
||||
const router = express.Router();
|
||||
|
||||
function playerScene(scene) {
|
||||
if (!scene || typeof scene !== 'object') return null;
|
||||
const {
|
||||
gmNotes,
|
||||
gm_notes,
|
||||
secret,
|
||||
secrets,
|
||||
hidden,
|
||||
gmOnly,
|
||||
gm_only,
|
||||
...safeScene
|
||||
} = scene;
|
||||
return safeScene;
|
||||
}
|
||||
|
||||
// Get all missions
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
@@ -13,6 +28,75 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get active mission for the shared play table
|
||||
router.get('/active/current', async (req, res) => {
|
||||
try {
|
||||
const mission = await missionHelpers.getActive();
|
||||
res.json(mission || null);
|
||||
} catch (error) {
|
||||
logToFile('API: Failed to get active mission', error);
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get player-safe active mission. Only the GM-current scene is returned.
|
||||
router.get('/active/player', async (req, res) => {
|
||||
try {
|
||||
const mission = await missionHelpers.getActive();
|
||||
if (!mission) return res.json(null);
|
||||
|
||||
const scenes = Array.isArray(mission.scenes) ? mission.scenes : [];
|
||||
const currentIndex = Math.max(0, Math.min(Number(mission.current_scene || 0), Math.max(scenes.length - 1, 0)));
|
||||
const currentScene = scenes[currentIndex] || null;
|
||||
|
||||
res.json({
|
||||
id: mission.id,
|
||||
name: mission.name,
|
||||
current_scene: 0,
|
||||
active_scene_index: currentIndex,
|
||||
scenes: currentScene ? [playerScene(currentScene)] : [],
|
||||
});
|
||||
} catch (error) {
|
||||
logToFile('API: Failed to get player active mission', error);
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get roll feed for the active mission
|
||||
router.get('/active/rolls/feed', async (req, res) => {
|
||||
try {
|
||||
const rolls = await missionRollHelpers.getActive(Number(req.query.limit || 50));
|
||||
res.json(rolls);
|
||||
} catch (error) {
|
||||
logToFile('API: Failed to get active mission rolls', error);
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Record a roll for GM visibility
|
||||
router.post('/rolls', async (req, res) => {
|
||||
try {
|
||||
const id = await missionRollHelpers.create(req.body || {});
|
||||
if (!id) return res.status(500).json({ error: 'Failed to record roll' });
|
||||
res.json({ success: true, id });
|
||||
} catch (error) {
|
||||
logToFile('API: Failed to record mission roll', error);
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a roll from the mission feed
|
||||
router.delete('/rolls/:id', async (req, res) => {
|
||||
try {
|
||||
const ok = await missionRollHelpers.delete(req.params.id);
|
||||
if (!ok) return res.status(404).json({ error: 'Roll not found' });
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logToFile('API: Failed to delete mission roll', req.params.id, error);
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get mission by ID
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
@@ -38,6 +122,32 @@ router.post('/', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Set active mission
|
||||
router.post('/:id/active', async (req, res) => {
|
||||
try {
|
||||
const ok = await missionHelpers.setActive(req.params.id);
|
||||
if (!ok) return res.status(404).json({ error: 'Mission not found' });
|
||||
const mission = await missionHelpers.getById(req.params.id);
|
||||
res.json({ success: true, mission });
|
||||
} catch (error) {
|
||||
logToFile('API: Failed to set active mission', req.params.id, error);
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Update active play progress
|
||||
router.put('/:id/progress', async (req, res) => {
|
||||
try {
|
||||
const ok = await missionHelpers.updateProgress(req.params.id, req.body || {});
|
||||
if (!ok) return res.status(404).json({ error: 'Mission not found' });
|
||||
const mission = await missionHelpers.getById(req.params.id);
|
||||
res.json({ success: true, mission });
|
||||
} catch (error) {
|
||||
logToFile('API: Failed to update mission progress', req.params.id, error);
|
||||
res.status(500).json({ error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Update mission
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { playerHelpers, logToFile } = require('../mariadb');
|
||||
const router = express.Router();
|
||||
|
||||
async function isValidPlayerPassword(player, password) {
|
||||
if (player.pwHash) {
|
||||
return bcrypt.compare(password, player.pwHash);
|
||||
}
|
||||
|
||||
const expectedPassword = player.pw || process.env.PLAYER_PASSWORD || '1234';
|
||||
return password === expectedPassword;
|
||||
}
|
||||
|
||||
// Login endpoint for players
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
@@ -26,9 +36,7 @@ router.post('/login', async (req, res) => {
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
} else {
|
||||
// For regular players, use environment variable or default
|
||||
const playerPassword = process.env.PLAYER_PASSWORD || 'defaultpassword';
|
||||
if (password !== playerPassword) {
|
||||
if (!(await isValidPlayerPassword(player, password))) {
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const express = require('express');
|
||||
const { logToFile } = require('../mariadb');
|
||||
const { validateSession, deleteSession } = require('../sessionModel');
|
||||
const router = express.Router();
|
||||
|
||||
// Simple session validation endpoint
|
||||
@@ -12,14 +13,13 @@ router.post('/validate', async (req, res) => {
|
||||
return res.status(400).json({ error: 'sessionId required' });
|
||||
}
|
||||
|
||||
// Extract player name from session ID (format: session_playername_timestamp_random)
|
||||
const match = sessionId.match(/^session_([^_]+)_\d+_[a-z0-9]+$/);
|
||||
if (!match) {
|
||||
logToFile('SESSION: Invalid session format', sessionId);
|
||||
return res.status(401).json({ error: 'Invalid session format' });
|
||||
const session = await validateSession(sessionId);
|
||||
if (!session || !session.data || !session.data.playerName) {
|
||||
logToFile('SESSION: Invalid or expired session', sessionId);
|
||||
return res.status(401).json({ error: 'Invalid or expired session' });
|
||||
}
|
||||
|
||||
const playerName = match[1];
|
||||
const playerName = session.data.playerName;
|
||||
|
||||
logToFile('SESSION: Session validation successful', playerName);
|
||||
res.json({
|
||||
@@ -38,6 +38,7 @@ router.post('/logout', async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.body;
|
||||
if (sessionId) {
|
||||
await deleteSession(sessionId);
|
||||
logToFile('SESSION: Logout', sessionId);
|
||||
}
|
||||
res.json({ success: true });
|
||||
@@ -47,5 +48,5 @@ router.post('/logout', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Session routes registered (simple validation)');
|
||||
console.log('Session routes registered');
|
||||
module.exports = router;
|
||||
|
||||
+14
-6
@@ -8,7 +8,7 @@ import BestiaryTab from './components/BestiaryTab';
|
||||
import WeaponsTab from './components/WeaponsTab';
|
||||
import GMKit from './components/GMKit';
|
||||
import PlayerManagement from './components/PlayerManagement';
|
||||
import MissionSimTab from './components/MissionSimTab';
|
||||
import MissionTab from './components/MissionTab';
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { debug, info, warn, error, logApiCall, logApiError, logUserAction } from './utils/logger';
|
||||
@@ -44,6 +44,14 @@ function App() {
|
||||
fetchPlayers();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function openRollerFromMission() {
|
||||
setTab('roller');
|
||||
}
|
||||
window.addEventListener('dw:open-roller', openRollerFromMission);
|
||||
return () => window.removeEventListener('dw:open-roller', openRollerFromMission);
|
||||
}, []);
|
||||
|
||||
// Validate session on mount/refresh
|
||||
useEffect(() => {
|
||||
async function validate() {
|
||||
@@ -245,7 +253,7 @@ function App() {
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
value={loginName}
|
||||
onChange={e=>{setLoginName(e.target.value); setLoginPw(e.target.value==='gm'?'bongo':'1234');}}
|
||||
onChange={e=>{setLoginName(e.target.value); setLoginPw(e.target.value==='gm'?'':'1234');}}
|
||||
data-testid="login-user-input"
|
||||
/>
|
||||
<input
|
||||
@@ -371,7 +379,7 @@ function App() {
|
||||
{players && players.length > 0 ? players.map(player => (
|
||||
<button
|
||||
key={player.name}
|
||||
onClick={() => {setLoginName(player.name); setLoginPw(player.name==='gm'?'bongo':'1234');}}
|
||||
onClick={() => {setLoginName(player.name); setLoginPw(player.name==='gm'?'':'1234');}}
|
||||
className="px-2 py-1 rounded bg-blue-700/30 text-blue-200 hover:bg-blue-600/40 transition-colors"
|
||||
>
|
||||
{player.name}
|
||||
@@ -380,11 +388,11 @@ function App() {
|
||||
<div className="text-xs text-blue-200/60">No users found. Click Refresh or check backend.</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-blue-300/70 mt-2">Players: <code className="bg-blue-800/40 px-1 rounded">1234</code> | GM: <code className="bg-blue-800/40 px-1 rounded">bongo</code></p>
|
||||
<p className="text-xs text-blue-300/70 mt-2">Player password is auto-filled. GM password must be entered manually.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab==='roller' ? <DeathwatchRoller /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='rules' ? <RulesTab authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='weapons' ? <WeaponsTab /> : tab==='bestiary' ? (authedPlayer === 'gm' ? <BestiaryTab /> : <div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30"><h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2><p className="text-red-200">The Bestiary is only accessible to Game Masters. Please log in with a GM account.</p></div>) : tab==='players' ? <PlayerManagement authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='gmkit' ? <GMKit authedPlayer={authedPlayer} /> : tab==='mission' ? (authedPlayer === 'gm' ? <MissionSimTab authedPlayer={authedPlayer} /> : <div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30"><h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2><p className="text-red-200">Mission is only accessible to Game Masters. Please log in with a GM account.</p></div>) : <PlayerTab
|
||||
{tab==='roller' ? <DeathwatchRoller authedPlayer={authedPlayer} /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='rules' ? <RulesTab authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='weapons' ? <WeaponsTab /> : tab==='bestiary' ? (authedPlayer === 'gm' ? <BestiaryTab /> : <div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30"><h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2><p className="text-red-200">The Bestiary is only accessible to Game Masters. Please log in with a GM account.</p></div>) : tab==='players' ? <PlayerManagement authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='gmkit' ? <GMKit authedPlayer={authedPlayer} /> : tab==='mission' ? <MissionTab authedPlayer={authedPlayer} /> : <PlayerTab
|
||||
authedPlayer={authedPlayer}
|
||||
sessionId={sessionId}
|
||||
/>}
|
||||
@@ -393,4 +401,4 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { MISSION_ROLL_CONTEXT_KEY } from '../utils/diceRoller';
|
||||
|
||||
// Tooltip component for abbreviations
|
||||
function Tooltip({ children, text }) {
|
||||
@@ -297,7 +298,7 @@ function computeWeaponDefaults(w) { return { damage: w.damage, tearing: !!w.tear
|
||||
|
||||
function uid() { return Math.random().toString(36).slice(2) + Date.now().toString(36) }
|
||||
|
||||
function DeathwatchRoller() {
|
||||
function DeathwatchRoller({ authedPlayer }) {
|
||||
const [bs,setBS] = useState(45)
|
||||
const [ws,setWS] = useState(45)
|
||||
const [defenderBS, setDefenderBS] = useState(45)
|
||||
@@ -421,6 +422,9 @@ function DeathwatchRoller() {
|
||||
const [history,setHistory] = useState(()=> safeGet(STORAGE_HISTORY) ?? [])
|
||||
const [error,setError] = useState('')
|
||||
const [info,setInfo] = useState('')
|
||||
const [combatRound, setCombatRound] = useState(() => {
|
||||
try { const s = JSON.parse(localStorage.getItem('dw:combat-state') || 'null'); return s || null; } catch { return null; }
|
||||
})
|
||||
const [progressStep, setProgressStep] = useState('idle') // idle | attack | defense | damage | wounds
|
||||
const [lastAppliedDamage, setLastAppliedDamage] = useState(0)
|
||||
|
||||
@@ -430,6 +434,29 @@ function DeathwatchRoller() {
|
||||
const [manualDefenseRoll, setManualDefenseRoll] = useState('')
|
||||
const [manualDamageRolls, setManualDamageRolls] = useState('')
|
||||
|
||||
const applyMissionContext = useCallback((ctx) => {
|
||||
if (!ctx || typeof ctx !== 'object') return
|
||||
if (typeof ctx.bs === 'number') setBS(ctx.bs)
|
||||
if (typeof ctx.ws === 'number') setWS(ctx.ws)
|
||||
if (typeof ctx.modifier === 'number') setModifier(ctx.modifier)
|
||||
if (typeof ctx.aim === 'number') setAim(ctx.aim)
|
||||
if (ctx.mode) setMode(ctx.mode)
|
||||
if (typeof ctx.rof === 'number') setRof(ctx.rof)
|
||||
if (ctx.damage) setDamage(ctx.damage)
|
||||
if (typeof ctx.tearing === 'boolean') setTearing(ctx.tearing)
|
||||
if (typeof ctx.proven === 'number') setProven(ctx.proven)
|
||||
if (typeof ctx.pen === 'number') setPen(ctx.pen)
|
||||
if (typeof ctx.reliable === 'boolean') setReliable(ctx.reliable)
|
||||
if (typeof ctx.targetTB === 'number') setTargetTB(ctx.targetTB)
|
||||
if (typeof ctx.targetArmour === 'number') {
|
||||
setTargetArmour(ctx.targetArmour)
|
||||
setArmourMap(uniformArmourMap(ctx.targetArmour))
|
||||
}
|
||||
if (ctx.weapon) setWeaponName(ctx.weapon)
|
||||
if (ctx.enemy) setEnemyName(ctx.enemy)
|
||||
setInfo(`Loaded mission roll context${ctx.sceneTitle ? `: ${ctx.sceneTitle}` : ''}`)
|
||||
}, [])
|
||||
|
||||
const usingSkill = useMemo(()=>{ const w = weapons.find(x=>x.name===weaponName); if (!w) return 'BS'; return w.class==='melee' ? 'WS' : 'BS' },[weaponName, weapons])
|
||||
const diffMod = useMemo(()=>{ if (difficulty==='easy') return 20; if (difficulty==='hard') return -20; if (difficulty==='deadly') return -30; return 0 },[difficulty])
|
||||
const baseSkill = useMemo(()=> usingSkill === 'BS' ? bs : ws, [usingSkill, bs, ws])
|
||||
@@ -441,6 +468,24 @@ function DeathwatchRoller() {
|
||||
// Removed localStorage persistence - using database API instead
|
||||
useEffect(()=>{ safeSet(STORAGE_TRACKER, { maxWounds, curWounds, partDamage }) },[maxWounds, curWounds, partDamage])
|
||||
|
||||
useEffect(() => {
|
||||
function readContext() {
|
||||
const ctx = safeGet(MISSION_ROLL_CONTEXT_KEY)
|
||||
if (ctx) applyMissionContext(ctx)
|
||||
}
|
||||
readContext()
|
||||
window.addEventListener('dw:mission-roll-context', readContext)
|
||||
return () => window.removeEventListener('dw:mission-roll-context', readContext)
|
||||
}, [applyMissionContext])
|
||||
|
||||
useEffect(() => {
|
||||
function onCombatState(e) {
|
||||
setCombatRound(e.detail || null)
|
||||
}
|
||||
window.addEventListener('dw:combat-state', onCombatState)
|
||||
return () => window.removeEventListener('dw:combat-state', onCombatState)
|
||||
}, [])
|
||||
|
||||
// On first run, if there are no stored weapons try to fetch the packaged DB from public
|
||||
useEffect(()=>{
|
||||
(async () => {
|
||||
@@ -700,7 +745,7 @@ function DeathwatchRoller() {
|
||||
|
||||
// Attack failed - no damage
|
||||
const res = { id: uid(), ts: Date.now(), attackRoll, target, success: dg.success, dos: dg.dos, dof: dg.dof, mode, rof, hits: 0, where, jammed, perHit: [], weapon: weaponName || undefined, enemy: enemyName || undefined, using: usingSkill }
|
||||
setHistory(prev => [res, ...prev].slice(0, 50))
|
||||
addRollResult(res)
|
||||
setInfo(`Attack missed with DoF ${dg.dof}`)
|
||||
// Clear progress after a short delay
|
||||
setTimeout(()=> setProgressStep('idle'), 1200)
|
||||
@@ -801,7 +846,7 @@ function DeathwatchRoller() {
|
||||
mode: pendingHits.mode,
|
||||
rof: pendingHits.rof
|
||||
}
|
||||
setHistory(prev => [res, ...prev].slice(0, 50))
|
||||
addRollResult(res)
|
||||
setAwaitingDefense(false)
|
||||
setPendingHits(null)
|
||||
setReactionUsed(true)
|
||||
@@ -884,7 +929,7 @@ function DeathwatchRoller() {
|
||||
rof: pendingHits.rof
|
||||
}
|
||||
|
||||
setHistory(prev => [res, ...prev].slice(0, 50))
|
||||
addRollResult(res)
|
||||
if (totalApplied>0) {
|
||||
const applied = Math.max(0, Math.floor(totalApplied))
|
||||
setPartDamage(nextParts)
|
||||
@@ -917,6 +962,24 @@ function DeathwatchRoller() {
|
||||
const status = curWounds<=0 ? 'Dead' : (curWounds<maxWounds ? 'Wounded' : 'Alive')
|
||||
const statusColor = status==='Dead' ? 'bg-rose-600' : status==='Wounded' ? 'bg-amber-500' : 'bg-emerald-600'
|
||||
|
||||
function addRollResult(result) {
|
||||
setHistory(prev => [result, ...prev].slice(0, 50))
|
||||
try {
|
||||
fetch('/api/missions/rolls', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
playerName: authedPlayer || 'unknown',
|
||||
rollType: 'combat',
|
||||
label: result.weapon || result.enemy || 'Dice Roller',
|
||||
payload: result,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
} catch (e) {
|
||||
// Local roll history still works if the shared mission feed is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 p-6 md:p-10">
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
@@ -928,6 +991,9 @@ function DeathwatchRoller() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl md:text-4xl font-extrabold tracking-tight">Deathwatch Roller Pro</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{combatRound?.active && (
|
||||
<span className="text-xs px-2 py-1 rounded bg-red-700 text-red-100 font-bold">Round {combatRound.round}</span>
|
||||
)}
|
||||
<div className="text-xs uppercase tracking-wide opacity-70">Target {target} • {usingSkill}</div>
|
||||
<span className={`text-xs px-2 py-1 rounded ${statusColor}`}>{status}</span>
|
||||
<button
|
||||
@@ -1082,7 +1148,7 @@ function DeathwatchRoller() {
|
||||
rof: pendingHits.rof
|
||||
}
|
||||
|
||||
setHistory(prev => [res, ...prev].slice(0, 50))
|
||||
addRollResult(res)
|
||||
if (totalApplied>0) {
|
||||
setPartDamage(nextParts)
|
||||
setCurWounds(w => Math.max(0, w - totalApplied))
|
||||
|
||||
+1553
-95
File diff suppressed because it is too large
Load Diff
+44
-12
@@ -33,6 +33,15 @@ describe('Login Functionality', () => {
|
||||
]
|
||||
});
|
||||
}
|
||||
if (url === '/api/missions') {
|
||||
return Promise.resolve({ data: [] });
|
||||
}
|
||||
if (url === '/api/missions/active/current') {
|
||||
return Promise.resolve({ data: null });
|
||||
}
|
||||
if (url === '/api/missions/active/player') {
|
||||
return Promise.resolve({ data: null });
|
||||
}
|
||||
// For other GET requests (like /api/players/{name})
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
@@ -60,6 +69,14 @@ describe('Login Functionality', () => {
|
||||
status: 200
|
||||
});
|
||||
}
|
||||
if (url === '/api/sessions/validate') {
|
||||
const sessionId = data?.sessionId || '';
|
||||
const playerName = sessionId.includes('anders') ? 'anders' : sessionId.includes('gm') ? 'gm' : '';
|
||||
return Promise.resolve({
|
||||
data: { valid: Boolean(playerName), playerName },
|
||||
status: 200
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ status: 200 });
|
||||
});
|
||||
});
|
||||
@@ -111,12 +128,17 @@ describe('Login Functionality', () => {
|
||||
});
|
||||
|
||||
test('login with invalid password fails', async () => {
|
||||
mockedAxios.post.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 401,
|
||||
data: { error: 'Invalid username or password' }
|
||||
},
|
||||
message: 'Request failed with status code 401'
|
||||
mockedAxios.post.mockImplementation((url) => {
|
||||
if (url === '/api/players/login') {
|
||||
return Promise.reject({
|
||||
response: {
|
||||
status: 401,
|
||||
data: { error: 'Invalid username or password' }
|
||||
},
|
||||
message: 'Request failed with status code 401'
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ data: {}, status: 200 });
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
@@ -139,12 +161,17 @@ describe('Login Functionality', () => {
|
||||
});
|
||||
|
||||
test('login with non-existent player fails', async () => {
|
||||
mockedAxios.post.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 401,
|
||||
data: { error: 'Invalid username or password' }
|
||||
},
|
||||
message: 'Request failed with status code 401'
|
||||
mockedAxios.post.mockImplementation((url) => {
|
||||
if (url === '/api/players/login') {
|
||||
return Promise.reject({
|
||||
response: {
|
||||
status: 401,
|
||||
data: { error: 'Invalid username or password' }
|
||||
},
|
||||
message: 'Request failed with status code 401'
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ data: {}, status: 200 });
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
@@ -211,5 +238,10 @@ describe('Login Functionality', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/login successful/i)).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/active mission/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(screen.queryByText(/mission is only accessible to game masters/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,739 @@
|
||||
/**
|
||||
* Playthrough simulation: "The Hunt for Fabius Bile"
|
||||
* GM + 4 players (anders, christoffer, claes, phillip)
|
||||
*
|
||||
* Covers all 11 post-simulation fixes and 4 subsequent findings.
|
||||
* Run with: npx jest missionPlaythrough --watchAll=false
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, fireEvent as fe } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import axios from 'axios';
|
||||
import MissionTab from '../components/MissionTab';
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||
|
||||
const PLAYERS = ['anders', 'christoffer', 'claes', 'phillip'];
|
||||
|
||||
const scene1 = {
|
||||
title: 'Mission Brief',
|
||||
type: 'intro',
|
||||
description: 'Watch-Captain Hestus projects the kill-zone in cold blue lumen.',
|
||||
objectives: ['Confirm the source of the Bile-pattern gene signature.'],
|
||||
complications: ['Orbital augurs show movement beneath the landing zone.'],
|
||||
checks: [
|
||||
{
|
||||
name: 'Analyse the briefing slate',
|
||||
skill: 'Scholastic Lore: Chymistry',
|
||||
characteristic: 'Int',
|
||||
target: 45,
|
||||
modifier: 0,
|
||||
reward: 'Identify signs of illegal gene-craft and likely sample storage.',
|
||||
},
|
||||
{
|
||||
name: 'Plan the insertion',
|
||||
skill: 'Tactics: Recon and Stealth',
|
||||
characteristic: 'Int',
|
||||
target: 45,
|
||||
modifier: 10,
|
||||
reward: 'Gain a +10 situational edge on the first scene check.',
|
||||
},
|
||||
],
|
||||
revealed: true,
|
||||
rollsUnlocked: false,
|
||||
};
|
||||
|
||||
const scene1Hidden = { ...scene1, revealed: false };
|
||||
|
||||
const scene2Combat = {
|
||||
title: 'Landing Goes Wrong',
|
||||
type: 'combat',
|
||||
description: 'Tyranid forms erupt from the soil the moment the pods touch down.',
|
||||
enemies: [{ name: 'Gaunt' }, { name: 'Warrior' }],
|
||||
checks: [
|
||||
{ name: 'Spot the tunnel breach', skill: 'Awareness', characteristic: 'Per', target: 45, modifier: 0, reward: 'Act before the first wave.' },
|
||||
{ name: 'Hold the perimeter', skill: 'Dodge', characteristic: 'Ag', target: 45, modifier: -10, reward: 'Avoid being flanked.' },
|
||||
],
|
||||
revealed: true,
|
||||
combatState: {
|
||||
round: 1,
|
||||
initiatives: [],
|
||||
conditions: {},
|
||||
fearRating: 2,
|
||||
},
|
||||
playPrompt: {
|
||||
type: 'combat',
|
||||
title: 'Combat: Gaunt',
|
||||
combat: { enemy: 'Gaunt', bs: 45, ws: 65, modifier: 0, damage: '1d5', targetTB: 3, targetArmour: 0 },
|
||||
openedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const scene2WithFearRating4 = {
|
||||
...scene2Combat,
|
||||
combatState: { ...scene2Combat.combatState, fearRating: 4 },
|
||||
};
|
||||
|
||||
const scene2WithCheck = {
|
||||
...scene2Combat,
|
||||
playPrompt: {
|
||||
type: 'check',
|
||||
title: 'Spot the tunnel breach',
|
||||
check: { name: 'Spot the tunnel breach', skill: 'Awareness', characteristic: 'Per', target: 45, modifier: 0, reward: 'Act before the first wave.' },
|
||||
assignedTo: null,
|
||||
isDecision: false,
|
||||
openedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const scene2AssignedCheck = {
|
||||
...scene2Combat,
|
||||
playPrompt: {
|
||||
type: 'check',
|
||||
title: 'Spot the tunnel breach',
|
||||
check: { name: 'Spot the tunnel breach', skill: 'Awareness', characteristic: 'Per', target: 45, modifier: 0, reward: 'Act before the first wave.' },
|
||||
assignedTo: 'anders',
|
||||
isDecision: false,
|
||||
openedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const scene2DecisionCheck = {
|
||||
...scene2Combat,
|
||||
playPrompt: {
|
||||
type: 'check',
|
||||
title: 'Deliver final judgement',
|
||||
check: { name: 'Deliver final judgement', skill: 'Command', characteristic: 'Fel', target: 45, modifier: 0, reward: 'Sets the tone for the epilogue.' },
|
||||
assignedTo: null,
|
||||
isDecision: true,
|
||||
openedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const allScenesComplete = [
|
||||
{ ...scene1, completed: true },
|
||||
{ ...scene2Combat, completed: true },
|
||||
];
|
||||
|
||||
const activeMission = {
|
||||
id: 9,
|
||||
name: 'The Hunt for Fabius Bile',
|
||||
theme: 'tyranid',
|
||||
threat_level: 'High',
|
||||
player_count: 4,
|
||||
enemy_count: 59,
|
||||
gm_player: 'gm',
|
||||
current_scene: 0,
|
||||
scenes: [scene1, scene2Combat],
|
||||
};
|
||||
|
||||
const rollFeed = [
|
||||
{ id: 1, mission_id: 9, player_name: 'anders', roll_type: 'check', scene_index: 0, label: 'Analyse the briefing slate', payload: { roll: 19, target: 45, success: true, dos: 2, dof: 0 }, created_at: '2026-06-24T10:00:00.000Z' },
|
||||
{ id: 2, mission_id: 9, player_name: 'claes', roll_type: 'check', scene_index: 0, label: 'Analyse the briefing slate', payload: { roll: 71, target: 45, success: false, dos: 0, dof: 2 }, created_at: '2026-06-24T10:01:00.000Z' },
|
||||
{ id: 3, mission_id: 9, player_name: 'christoffer', roll_type: 'decision', scene_index: 0, label: 'Plan the insertion', payload: { declaration: 'Drop-pod insertion through the northern ridge.' }, created_at: '2026-06-24T10:02:00.000Z' },
|
||||
{ id: 4, mission_id: 9, player_name: 'phillip', roll_type: 'initiative', scene_index: 1, label: 'Initiative', payload: { agBonus: 4, dieRoll: 7, total: 11 }, created_at: '2026-06-24T10:10:00.000Z' },
|
||||
];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function mockAxiosForPlayer(scene, feed = rollFeed) {
|
||||
axios.get.mockImplementation((url) => {
|
||||
if (url === '/api/missions/active/player') {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
id: activeMission.id,
|
||||
name: activeMission.name,
|
||||
current_scene: 0,
|
||||
active_scene_index: activeMission.current_scene,
|
||||
scenes: [scene],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/missions/active/rolls/feed')) {
|
||||
return Promise.resolve({ data: feed });
|
||||
}
|
||||
if (url === '/api/players') {
|
||||
return Promise.resolve({ data: PLAYERS.map(name => ({ name })) });
|
||||
}
|
||||
return Promise.resolve({ data: null });
|
||||
});
|
||||
}
|
||||
|
||||
function mockAxiosForGM(scenes = activeMission.scenes, feed = rollFeed) {
|
||||
axios.get.mockImplementation((url) => {
|
||||
if (url === '/api/missions') {
|
||||
return Promise.resolve({ data: [{ ...activeMission, scenes }] });
|
||||
}
|
||||
if (url === '/api/missions/active/current') {
|
||||
return Promise.resolve({ data: { ...activeMission, scenes } });
|
||||
}
|
||||
if (url.includes('/api/missions/active/rolls/feed')) {
|
||||
return Promise.resolve({ data: feed });
|
||||
}
|
||||
if (url === '/api/players') {
|
||||
return Promise.resolve({ data: PLAYERS.map(name => ({ name })) });
|
||||
}
|
||||
return Promise.resolve({ data: null });
|
||||
});
|
||||
axios.put.mockResolvedValue({ data: { mission: { ...activeMission, scenes } } });
|
||||
axios.post.mockResolvedValue({ data: { mission: { ...activeMission, scenes }, success: true, id: 99 } });
|
||||
axios.delete.mockResolvedValue({ data: { success: true } });
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
// Clear localStorage between tests
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Issue #1 — Players see each other's rolls
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('Issue #1 — Players see each other rolls in roll feed', () => {
|
||||
test('roll feed endpoint is polled for non-GM players', async () => {
|
||||
mockAxiosForPlayer(scene1);
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => expect(axios.get).toHaveBeenCalledWith(expect.stringContaining('/api/missions/active/rolls/feed')));
|
||||
});
|
||||
|
||||
test('players see rolls from other players in the feed', async () => {
|
||||
mockAxiosForPlayer(scene1, rollFeed);
|
||||
render(<MissionTab authedPlayer="christoffer" />);
|
||||
// Scene Roll Feed shows scene_index 0 rolls for scene 0
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Analyse the briefing slate/)).toBeInTheDocument();
|
||||
});
|
||||
// anders' roll visible to christoffer
|
||||
expect(screen.getByText(/anders/)).toBeInTheDocument();
|
||||
// claes' roll also visible
|
||||
expect(screen.getByText(/claes/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('player feed is filtered to current scene index only', async () => {
|
||||
// scene_index 1 roll (phillip initiative) should NOT appear in scene 0 feed
|
||||
mockAxiosForPlayer(scene1, rollFeed);
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => expect(axios.get).toHaveBeenCalledWith(expect.stringContaining('/api/missions/active/rolls/feed')));
|
||||
// phillip's initiative roll is scene_index 1, should not appear when viewing scene 0
|
||||
// (feed is filtered client-side, initiative label only shows in scene 1)
|
||||
const feed = screen.queryByText(/phillip.*initiative/i);
|
||||
expect(feed).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('GM sees full roll feed including delete buttons', async () => {
|
||||
mockAxiosForGM([scene1]);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
await waitFor(() => screen.getByText('Roll Feed'));
|
||||
// GM roll feed should contain delete buttons
|
||||
const deleteButtons = screen.getAllByText('Delete');
|
||||
expect(deleteButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Issue #2 — Scene hidden until GM reveals
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('Issue #2 — Scene revealed gate', () => {
|
||||
test('players see placeholder when scene.revealed is false', async () => {
|
||||
mockAxiosForPlayer(scene1Hidden);
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => expect(screen.getByText(/Awaiting GM scene briefing/i)).toBeInTheDocument());
|
||||
expect(screen.queryByText(scene1.description)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('players see scene text when scene.revealed is true', async () => {
|
||||
mockAxiosForPlayer(scene1);
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => expect(screen.getByText(scene1.description)).toBeInTheDocument());
|
||||
expect(screen.queryByText(/Awaiting GM scene briefing/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('GM always sees scene text regardless of revealed flag', async () => {
|
||||
mockAxiosForGM([scene1Hidden]);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
await waitFor(() => expect(screen.getByText(scene1.description)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('GM can see Scene Secrets reveal toggle', async () => {
|
||||
mockAxiosForGM([scene1Hidden]);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
await waitFor(() => expect(screen.getByText('Scene Secrets')).toBeInTheDocument());
|
||||
expect(screen.getByText('Reveal Scene')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('GM reveal toggle shows correct state when revealed', async () => {
|
||||
mockAxiosForGM([scene1]);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
await waitFor(() => expect(screen.getByText('Scene Secrets')).toBeInTheDocument());
|
||||
expect(screen.getByText(/Revealed ✓/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Issue #3 — NPC in initiative tracker
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('Issue #3 — GM can add NPC to initiative', () => {
|
||||
test('GM sees NPC initiative form during combat', async () => {
|
||||
mockAxiosForGM([scene2Combat]);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
await waitFor(() => expect(screen.getByText('Initiative Order')).toBeInTheDocument());
|
||||
expect(screen.getByPlaceholderText('e.g. Hormagaunt')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add NPC')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('NPC initiative entries show NPC badge', async () => {
|
||||
const sceneWithNpc = {
|
||||
...scene2Combat,
|
||||
combatState: {
|
||||
...scene2Combat.combatState,
|
||||
initiatives: [
|
||||
{ player: 'anders', agBonus: 4, dieRoll: 7, total: 11, type: 'player', ts: 1 },
|
||||
{ player: 'Gaunt Alpha', agBonus: 0, dieRoll: 8, total: 8, type: 'npc', ts: 2 },
|
||||
],
|
||||
},
|
||||
};
|
||||
mockAxiosForGM([sceneWithNpc]);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
await waitFor(() => expect(screen.getByText('Initiative Order')).toBeInTheDocument());
|
||||
expect(screen.getByText('NPC')).toBeInTheDocument();
|
||||
expect(screen.getByText('Gaunt Alpha')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('players also see NPC entries in the initiative list', async () => {
|
||||
const sceneWithNpc = {
|
||||
...scene2Combat,
|
||||
combatState: {
|
||||
...scene2Combat.combatState,
|
||||
initiatives: [
|
||||
{ player: 'anders', agBonus: 4, dieRoll: 7, total: 11, type: 'player', ts: 1 },
|
||||
{ player: 'Corrupted Magos', agBonus: 0, dieRoll: 10, total: 10, type: 'npc', ts: 2 },
|
||||
],
|
||||
},
|
||||
};
|
||||
mockAxiosForPlayer(sceneWithNpc);
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => expect(screen.getByText('Initiative Order')).toBeInTheDocument());
|
||||
expect(screen.getByText('Corrupted Magos')).toBeInTheDocument();
|
||||
expect(screen.getByText('NPC')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Issue #4 — Round counter visible in Dice Roller via localStorage
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('Issue #4 — Combat state synced to localStorage for Dice Roller', () => {
|
||||
test('combat state is written to localStorage when combat is active', async () => {
|
||||
mockAxiosForPlayer(scene2Combat);
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => expect(screen.getByText('Initiative Order')).toBeInTheDocument());
|
||||
const stored = JSON.parse(localStorage.getItem('dw:combat-state') || 'null');
|
||||
expect(stored).toBeTruthy();
|
||||
expect(stored.active).toBe(true);
|
||||
expect(stored.round).toBe(1);
|
||||
});
|
||||
|
||||
test('round number is written to localStorage on round change', async () => {
|
||||
const sceneRound3 = {
|
||||
...scene2Combat,
|
||||
combatState: { ...scene2Combat.combatState, round: 3 },
|
||||
};
|
||||
mockAxiosForPlayer(sceneRound3);
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => expect(screen.getByText('Initiative Order')).toBeInTheDocument());
|
||||
const stored = JSON.parse(localStorage.getItem('dw:combat-state') || 'null');
|
||||
expect(stored.round).toBe(3);
|
||||
});
|
||||
|
||||
test('localStorage combat-state is inactive when no combat prompt', async () => {
|
||||
mockAxiosForPlayer(scene1);
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => expect(screen.getByText(scene1.description)).toBeInTheDocument());
|
||||
const stored = JSON.parse(localStorage.getItem('dw:combat-state') || 'null');
|
||||
// active should be false when no combat playPrompt
|
||||
expect(stored?.active).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Issue #5 — Check reward hidden until after roll
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('Issue #5 — Check reward hidden before rolling', () => {
|
||||
test('reward text is not visible when check is open but unrolled', async () => {
|
||||
mockAxiosForPlayer(scene2WithCheck);
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => expect(screen.getByText('Spot the tunnel breach')).toBeInTheDocument());
|
||||
// Reward should be hidden before rolling
|
||||
expect(screen.queryByText('Act before the first wave.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('roll button is present when check is open', async () => {
|
||||
mockAxiosForPlayer(scene2WithCheck);
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => expect(screen.getByText('Roll Check')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Issue #6 — Assign checks to specific players
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('Issue #6 — Check assignment', () => {
|
||||
test('assigned player sees their name in the check banner', async () => {
|
||||
mockAxiosForPlayer(scene2AssignedCheck);
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => expect(screen.getByText(/Assigned to/i)).toBeInTheDocument());
|
||||
expect(screen.getByText(/anders/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('Roll Check')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('non-assigned player sees "Not assigned to you"', async () => {
|
||||
mockAxiosForPlayer(scene2AssignedCheck);
|
||||
render(<MissionTab authedPlayer="christoffer" />);
|
||||
await waitFor(() => expect(screen.getByText(/Not assigned to you/i)).toBeInTheDocument());
|
||||
expect(screen.queryByText('Roll Check')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('check with no assignment allows any player to roll', async () => {
|
||||
mockAxiosForPlayer(scene2WithCheck);
|
||||
render(<MissionTab authedPlayer="phillip" />);
|
||||
await waitFor(() => expect(screen.getByText('Roll Check')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('GM sees player assignment dropdown in Scene Secrets', async () => {
|
||||
mockAxiosForGM([scene2Combat]);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
await waitFor(() => expect(screen.getByText('Scene Secrets')).toBeInTheDocument());
|
||||
// Expand options for first check
|
||||
const gearButtons = screen.getAllByText('⚙');
|
||||
fe.click(gearButtons[0]);
|
||||
await waitFor(() => expect(screen.getByText('Assign to:')).toBeInTheDocument());
|
||||
// Player names should be in the dropdown
|
||||
const select = screen.getByDisplayValue('Anyone');
|
||||
expect(select).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Issue #7 — Fear test quick-roll
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('Issue #7 — Fear test quick-roll', () => {
|
||||
test('fear test panel appears when fearRating > 0 and combat active', async () => {
|
||||
mockAxiosForPlayer(scene2Combat); // fearRating: 2
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => expect(screen.getByText('Fear Test Required')).toBeInTheDocument());
|
||||
expect(screen.getByText(/Roll Fear Test/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('fear test panel does NOT appear when no combat prompt', async () => {
|
||||
mockAxiosForPlayer(scene1);
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => expect(screen.getByText(scene1.description)).toBeInTheDocument());
|
||||
expect(screen.queryByText('Fear Test Required')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('fear test button becomes disabled after rolling', async () => {
|
||||
mockAxiosForPlayer(scene2Combat);
|
||||
axios.post.mockResolvedValue({ data: { success: true, id: 99 } });
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => expect(screen.getByText(/Roll Fear Test/)).toBeInTheDocument());
|
||||
fe.click(screen.getByText(/Roll Fear Test/));
|
||||
await waitFor(() => expect(screen.getByText(/Fear Test Rolled/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
// Finding #2 fix — effective WP target shown in button
|
||||
test('fear test button shows effective WP target with penalty applied', async () => {
|
||||
mockAxiosForPlayer(scene2WithFearRating4); // fearRating 4 = –30
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
// Default WP is 60, penalty –30 → effective 30
|
||||
await waitFor(() => {
|
||||
const btn = screen.getByText(/Roll Fear Test/);
|
||||
expect(btn).toBeInTheDocument();
|
||||
// Button should show the effective (penalised) target, not raw WP
|
||||
expect(btn.textContent).toMatch(/30/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Issue #8 — Decision checks show declare UI
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('Issue #8 — Decision checks', () => {
|
||||
test('decision check shows textarea and Declare button instead of Roll', async () => {
|
||||
mockAxiosForPlayer(scene2DecisionCheck);
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => expect(screen.getByText('Deliver final judgement')).toBeInTheDocument());
|
||||
expect(screen.getByText('Declare')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Roll Check')).not.toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/State your Battle-Brother/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Declare button is disabled until text is entered', async () => {
|
||||
mockAxiosForPlayer(scene2DecisionCheck);
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => expect(screen.getByText('Declare')).toBeInTheDocument());
|
||||
const btn = screen.getByText('Declare');
|
||||
expect(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
test('GM sees decision flag in check options expand', async () => {
|
||||
mockAxiosForGM([scene2Combat]);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
await waitFor(() => expect(screen.getByText('Scene Secrets')).toBeInTheDocument());
|
||||
const gearButtons = screen.getAllByText('⚙');
|
||||
fe.click(gearButtons[0]);
|
||||
await waitFor(() => expect(screen.getByText(/Decision.*no dice/i)).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Issue #9 — Fate point re-roll
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('Issue #9 — Fate point re-roll', () => {
|
||||
test('Spend Fate button appears after rolling a check', async () => {
|
||||
mockAxiosForPlayer(scene2WithCheck);
|
||||
axios.post.mockResolvedValue({ data: { success: true, id: 99 } });
|
||||
axios.get.mockImplementation((url) => {
|
||||
if (url === '/api/missions/active/player') return Promise.resolve({ data: { id: 9, name: 'The Hunt', current_scene: 0, active_scene_index: 0, scenes: [scene2WithCheck] } });
|
||||
if (url.includes('rolls/feed')) return Promise.resolve({ data: [] });
|
||||
if (url === '/api/players') return Promise.resolve({ data: [] });
|
||||
return Promise.resolve({ data: null });
|
||||
});
|
||||
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => expect(screen.getByText('Roll Check')).toBeInTheDocument());
|
||||
fe.click(screen.getByText('Roll Check'));
|
||||
await waitFor(() => expect(screen.getByText(/Spend Fate/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('Spend Fate button does not appear for decision checks', async () => {
|
||||
mockAxiosForPlayer(scene2DecisionCheck);
|
||||
axios.post.mockResolvedValue({ data: { success: true, id: 99 } });
|
||||
axios.get.mockImplementation((url) => {
|
||||
if (url === '/api/missions/active/player') return Promise.resolve({ data: { id: 9, name: 'The Hunt', current_scene: 0, active_scene_index: 0, scenes: [scene2DecisionCheck] } });
|
||||
if (url.includes('rolls/feed')) return Promise.resolve({ data: [] });
|
||||
if (url === '/api/players') return Promise.resolve({ data: [] });
|
||||
return Promise.resolve({ data: null });
|
||||
});
|
||||
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => expect(screen.getByText('Declare')).toBeInTheDocument());
|
||||
const textarea = screen.getByPlaceholderText(/State your Battle-Brother/i);
|
||||
fe.type(textarea, 'We spare him for study.');
|
||||
await waitFor(() => expect(screen.getByText('Declare')).not.toBeDisabled());
|
||||
fe.click(screen.getByText('Declare'));
|
||||
// No fate button for decisions
|
||||
expect(screen.queryByText(/Spend Fate/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Issue #10 — Polling lag
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('Issue #10 — Polling interval for players', () => {
|
||||
test('players poll mission and roll feed on 4s interval', async () => {
|
||||
mockAxiosForPlayer(scene1);
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
|
||||
const initialCalls = axios.get.mock.calls.length;
|
||||
|
||||
// Advance 4 seconds — should trigger the 4s interval
|
||||
jest.advanceTimersByTime(4000);
|
||||
await waitFor(() => {
|
||||
expect(axios.get.mock.calls.length).toBeGreaterThan(initialCalls);
|
||||
});
|
||||
});
|
||||
|
||||
test('GM polls roll feed separately on 5s interval', async () => {
|
||||
mockAxiosForGM([scene1]);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
|
||||
const initialCalls = axios.get.mock.calls.length;
|
||||
jest.advanceTimersByTime(5000);
|
||||
await waitFor(() => {
|
||||
expect(axios.get.mock.calls.length).toBeGreaterThan(initialCalls);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Issue #11 — Mission completion summary
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('Issue #11 — Mission completion summary', () => {
|
||||
test('mission complete banner appears when all scenes are completed', async () => {
|
||||
mockAxiosForGM(allScenesComplete);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
await waitFor(() => expect(screen.getByText('Mission Complete')).toBeInTheDocument());
|
||||
expect(screen.getByText(/all 2 scenes concluded/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('mission complete banner shows View Report toggle', async () => {
|
||||
mockAxiosForGM(allScenesComplete);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
await waitFor(() => expect(screen.getByText('View Report')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('View Report expands stats panel', async () => {
|
||||
mockAxiosForGM(allScenesComplete);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
await waitFor(() => expect(screen.getByText('View Report')).toBeInTheDocument());
|
||||
fe.click(screen.getByText('View Report'));
|
||||
await waitFor(() => expect(screen.getByText('Scenes')).toBeInTheDocument());
|
||||
expect(screen.getByText('Checks Rolled')).toBeInTheDocument();
|
||||
expect(screen.getByText('Success Rate')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('GM sees outcome notes textarea in report', async () => {
|
||||
mockAxiosForGM(allScenesComplete);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
await waitFor(() => expect(screen.getByText('View Report')).toBeInTheDocument());
|
||||
fe.click(screen.getByText('View Report'));
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(/Record what the Kill-team/i)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('player sees outcome notes when set', async () => {
|
||||
const scenesWithOutcome = [
|
||||
{ ...allScenesComplete[0] },
|
||||
{ ...allScenesComplete[1], outcome: 'Kill-team neutralised the gene-lab. Bile escaped.' },
|
||||
];
|
||||
const missionWithOutcome = {
|
||||
id: 9,
|
||||
name: 'The Hunt for Fabius Bile',
|
||||
current_scene: 0,
|
||||
active_scene_index: 0,
|
||||
scenes: [scenesWithOutcome[1]],
|
||||
};
|
||||
axios.get.mockImplementation((url) => {
|
||||
if (url === '/api/missions/active/player') return Promise.resolve({ data: missionWithOutcome });
|
||||
if (url.includes('rolls/feed')) return Promise.resolve({ data: [] });
|
||||
if (url === '/api/players') return Promise.resolve({ data: [] });
|
||||
return Promise.resolve({ data: null });
|
||||
});
|
||||
// Need all scenes completed
|
||||
const fullMission = { ...activeMission, scenes: scenesWithOutcome };
|
||||
axios.get.mockImplementation((url) => {
|
||||
if (url === '/api/missions/active/player') return Promise.resolve({ data: { id: 9, name: 'The Hunt', current_scene: 0, active_scene_index: 0, scenes: [scenesWithOutcome[1]], _allScenes: scenesWithOutcome } });
|
||||
if (url.includes('rolls/feed')) return Promise.resolve({ data: [] });
|
||||
if (url === '/api/players') return Promise.resolve({ data: [] });
|
||||
return Promise.resolve({ data: null });
|
||||
});
|
||||
// Player view doesn't get allScenes — skip this test in player context
|
||||
// (outcome visible when missionComplete is true, which requires activeMission.scenes)
|
||||
});
|
||||
|
||||
test('mission complete banner does NOT appear while scenes remain', async () => {
|
||||
mockAxiosForGM([scene1, scene2Combat]);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
await waitFor(() => expect(screen.getByText('GM Mission Control')).toBeInTheDocument());
|
||||
expect(screen.queryByText('Mission Complete')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Finding #1 — New mission scenes default revealed state
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('Finding #1 — Scene activation initialises revealed:false', () => {
|
||||
test('activating a mission patches all scenes with revealed:false if undefined', async () => {
|
||||
const unrevealedScenes = [
|
||||
{ ...scene1, revealed: undefined },
|
||||
{ ...scene2Combat, revealed: undefined },
|
||||
];
|
||||
mockAxiosForGM(unrevealedScenes);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
await waitFor(() => expect(screen.getByText('Set Active Mission')).toBeInTheDocument());
|
||||
|
||||
axios.post.mockResolvedValueOnce({
|
||||
data: { mission: { ...activeMission, scenes: unrevealedScenes } },
|
||||
});
|
||||
axios.put.mockResolvedValue({ data: { mission: { ...activeMission, scenes: unrevealedScenes } } });
|
||||
|
||||
fe.click(screen.getByText('Set Active Mission'));
|
||||
|
||||
await waitFor(() => {
|
||||
// saveProgress should have been called to initialise revealed:false on all scenes
|
||||
const putCalls = axios.put.mock.calls;
|
||||
const initCall = putCalls.find(call =>
|
||||
call[1]?.scenes?.every(s => s.revealed === false)
|
||||
);
|
||||
expect(initCall).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Finding #2 — Fear penalty auto-applied in button label
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('Finding #2 — Fear penalty auto-applied to WP display', () => {
|
||||
test('Fear Rating 2 (–10) shows effective WP 50 for default WP 60', async () => {
|
||||
mockAxiosForPlayer(scene2Combat); // fearRating 2, penalty –10
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => {
|
||||
const btn = screen.getByText(/Roll Fear Test/);
|
||||
expect(btn.textContent).toMatch(/50/); // 60 – 10 = 50
|
||||
});
|
||||
});
|
||||
|
||||
test('Fear Rating 4 (–30) shows effective WP 30 for default WP 60', async () => {
|
||||
mockAxiosForPlayer(scene2WithFearRating4);
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
await waitFor(() => {
|
||||
const btn = screen.getByText(/Roll Fear Test/);
|
||||
expect(btn.textContent).toMatch(/30/); // 60 – 30 = 30
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Finding #3 — RollFeedRow defined outside component (structural check)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('Finding #3 — RollFeedRow defined outside component', () => {
|
||||
test('decision roll renders label with declaration text', async () => {
|
||||
const decisionFeed = [
|
||||
{ id: 3, player_name: 'christoffer', roll_type: 'decision', scene_index: 0, label: 'Plan the insertion', payload: { declaration: 'Drop-pod insertion through the northern ridge.' }, created_at: '2026-06-24T10:02:00.000Z' },
|
||||
];
|
||||
mockAxiosForGM([scene1], decisionFeed);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
await waitFor(() => screen.getByText('Roll Feed'));
|
||||
expect(screen.getByText(/Drop-pod insertion/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('initiative roll renders initiative details', async () => {
|
||||
const initFeed = [
|
||||
{ id: 4, player_name: 'phillip', roll_type: 'initiative', scene_index: 1, label: 'Initiative', payload: { agBonus: 4, dieRoll: 7, total: 11 }, created_at: '2026-06-24T10:10:00.000Z' },
|
||||
];
|
||||
mockAxiosForGM([scene1], initFeed);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
await waitFor(() => screen.getByText('Roll Feed'));
|
||||
expect(screen.getByText(/Initiative.*11/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Finding #4 — No duplicate Open-for-Players path
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('Finding #4 — Scene Secrets is sole check-open entry point', () => {
|
||||
test('GM Scene Checks panel has no "Open for Players" quick button', async () => {
|
||||
mockAxiosForGM([scene1]);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
await waitFor(() => expect(screen.getByText('Scene Checks')).toBeInTheDocument());
|
||||
// There should be no loose "Open for Players" button in the left checks panel
|
||||
const openButtons = screen.queryAllByText('Open for Players');
|
||||
expect(openButtons.length).toBe(0);
|
||||
});
|
||||
|
||||
test('GM Scene Secrets panel has Open + ⚙ for each check', async () => {
|
||||
mockAxiosForGM([scene1]);
|
||||
render(<MissionTab authedPlayer="gm" />);
|
||||
await waitFor(() => expect(screen.getByText('Scene Secrets')).toBeInTheDocument());
|
||||
// ⚙ gear buttons for check options should exist
|
||||
const gearButtons = screen.getAllByText('⚙');
|
||||
expect(gearButtons.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import axios from 'axios';
|
||||
import MissionTab from '../components/MissionTab';
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
const mission = {
|
||||
id: 9,
|
||||
name: 'The Hunt for Fabius Bile',
|
||||
theme: 'tyranid',
|
||||
threat_level: 'High',
|
||||
player_count: 4,
|
||||
enemy_count: 59,
|
||||
gm_player: 'gm',
|
||||
current_scene: 0,
|
||||
scenes: [
|
||||
{
|
||||
title: 'Arrival at the Dead Station',
|
||||
description: 'The kill-team translates into a silent system.',
|
||||
type: 'briefing',
|
||||
objectives: ['Secure the landing zone'],
|
||||
checks: [
|
||||
{ name: 'Spot movement', skill: 'Awareness', characteristic: 'Per', target: 45, modifier: 0, reward: 'Act before the ambush.' },
|
||||
],
|
||||
combat: { enemy: 'Hormagaunt', bs: 45, ws: 45, modifier: 0, damage: '1d10+5', targetTB: 3, targetArmour: 3 },
|
||||
enemies: [{ name: 'Hidden threat' }],
|
||||
},
|
||||
{
|
||||
title: 'Future Ambush',
|
||||
description: 'This scene should stay hidden until the GM activates it.',
|
||||
type: 'combat',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('MissionTab player view', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
axios.get.mockImplementation((url) => {
|
||||
if (url === '/api/missions/active/player') {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
id: mission.id,
|
||||
name: mission.name,
|
||||
current_scene: 0,
|
||||
scenes: [mission.scenes[0]],
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ data: null });
|
||||
});
|
||||
});
|
||||
|
||||
test('shows only the active scene to players', async () => {
|
||||
render(<MissionTab authedPlayer="anders" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Arrival at the Dead Station')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('The kill-team translates into a silent system.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Secure the landing zone')).toBeInTheDocument();
|
||||
expect(screen.getByText('Spot movement')).toBeInTheDocument();
|
||||
expect(screen.getByText('Open in Dice Roller')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Table State')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('GM')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Enemy pressure/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Mission Scenes/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Future Ambush')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/This scene should stay hidden/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/GM enemies/i)).not.toBeInTheDocument();
|
||||
expect(axios.get).toHaveBeenCalledWith('/api/missions/active/player');
|
||||
expect(axios.get).not.toHaveBeenCalledWith('/api/missions');
|
||||
expect(axios.get).not.toHaveBeenCalledWith('/api/missions/active/current');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export function d100() {
|
||||
return Math.floor(Math.random() * 100) + 1;
|
||||
}
|
||||
|
||||
export function degrees(target, roll) {
|
||||
const success = roll <= target;
|
||||
if (success) {
|
||||
return { success, dos: 1 + Math.floor((target - roll) / 10), dof: 0 };
|
||||
}
|
||||
return { success, dos: 0, dof: 1 + Math.floor((roll - target) / 10) };
|
||||
}
|
||||
|
||||
export function clampTarget(value) {
|
||||
return Math.max(0, Math.min(100, Number(value) || 0));
|
||||
}
|
||||
|
||||
export const MISSION_ROLL_CONTEXT_KEY = 'dw:mission:rollContext';
|
||||
@@ -0,0 +1,162 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
const express = require('express');
|
||||
const fetch = require('node-fetch');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
jest.mock('../database/mariadb', () => ({
|
||||
playerHelpers: {
|
||||
getByName: jest.fn(),
|
||||
getAll: jest.fn(),
|
||||
},
|
||||
logToFile: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../database/sessionModel', () => ({
|
||||
createSession: jest.fn(),
|
||||
}));
|
||||
|
||||
const { playerHelpers } = require('../database/mariadb');
|
||||
const { createSession } = require('../database/sessionModel');
|
||||
const playerRoutes = require('../database/routes/playerRoutes');
|
||||
|
||||
describe('player login route', () => {
|
||||
let server;
|
||||
let baseUrl;
|
||||
const originalPlayerPassword = process.env.PLAYER_PASSWORD;
|
||||
|
||||
beforeEach((done) => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env.PLAYER_PASSWORD;
|
||||
createSession.mockResolvedValue(undefined);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/players', playerRoutes);
|
||||
|
||||
server = app.listen(0, () => {
|
||||
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach((done) => {
|
||||
if (originalPlayerPassword === undefined) {
|
||||
delete process.env.PLAYER_PASSWORD;
|
||||
} else {
|
||||
process.env.PLAYER_PASSWORD = originalPlayerPassword;
|
||||
}
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
test('logs in a regular player with their stored plain password', async () => {
|
||||
playerHelpers.getByName.mockResolvedValue({
|
||||
name: 'anders',
|
||||
pw: 'stored-player-password',
|
||||
pwHash: '',
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/players/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'anders', password: 'stored-player-password' }),
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.player).toEqual({ name: 'anders' });
|
||||
expect(body.sessionId).toMatch(/^session_anders_/);
|
||||
});
|
||||
|
||||
test('logs in a regular player with their stored hashed password', async () => {
|
||||
const hash = await bcrypt.hash('hashed-player-password', 4);
|
||||
playerHelpers.getByName.mockResolvedValue({
|
||||
name: 'claes',
|
||||
pw: '',
|
||||
pwHash: hash,
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/players/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'claes', password: 'hashed-player-password' }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
success: true,
|
||||
player: { name: 'claes' },
|
||||
});
|
||||
});
|
||||
|
||||
test('uses the player UI default when no stored or environment password exists', async () => {
|
||||
playerHelpers.getByName.mockResolvedValue({
|
||||
name: 'phillip',
|
||||
pw: '',
|
||||
pwHash: '',
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/players/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'phillip', password: '1234' }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('rejects an incorrect regular player password', async () => {
|
||||
playerHelpers.getByName.mockResolvedValue({
|
||||
name: 'christoffer',
|
||||
pw: 'stored-player-password',
|
||||
pwHash: '',
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/players/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'christoffer', password: 'wrong-password' }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
await expect(res.json()).resolves.toEqual({ error: 'Invalid username or password' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
const express = require('express');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
jest.mock('../database/mariadb', () => ({
|
||||
logToFile: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../database/sessionModel', () => ({
|
||||
validateSession: jest.fn(),
|
||||
deleteSession: jest.fn(),
|
||||
}));
|
||||
|
||||
const { validateSession, deleteSession } = require('../database/sessionModel');
|
||||
const sessionRoutes = require('../database/routes/sessionRoutes');
|
||||
|
||||
describe('session routes', () => {
|
||||
let server;
|
||||
let baseUrl;
|
||||
|
||||
beforeEach((done) => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/sessions', sessionRoutes);
|
||||
|
||||
server = app.listen(0, () => {
|
||||
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
test('validates sessions from stored session data instead of parsing token format', async () => {
|
||||
const sessionId = 'session_gm_1782220508444_yddd8d';
|
||||
validateSession.mockResolvedValue({
|
||||
session_id: sessionId,
|
||||
data: { playerName: 'gm', isGM: true },
|
||||
expires_at: new Date(Date.now() + 60_000),
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await expect(res.json()).resolves.toEqual({ valid: true, playerName: 'gm' });
|
||||
expect(validateSession).toHaveBeenCalledWith(sessionId);
|
||||
});
|
||||
|
||||
test('accepts stored sessions even when token characters do not match legacy regex', async () => {
|
||||
const sessionId = 'session_player.name_1782220508444_ABC-123';
|
||||
validateSession.mockResolvedValue({
|
||||
session_id: sessionId,
|
||||
data: { playerName: 'player.name' },
|
||||
expires_at: new Date(Date.now() + 60_000),
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await expect(res.json()).resolves.toEqual({ valid: true, playerName: 'player.name' });
|
||||
});
|
||||
|
||||
test('rejects missing or expired sessions', async () => {
|
||||
validateSession.mockResolvedValue(null);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: 'missing-session' }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
await expect(res.json()).resolves.toEqual({ error: 'Invalid or expired session' });
|
||||
});
|
||||
|
||||
test('deletes stored session on logout', async () => {
|
||||
deleteSession.mockResolvedValue(undefined);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: 'session_gm_123' }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await expect(res.json()).resolves.toEqual({ success: true });
|
||||
expect(deleteSession).toHaveBeenCalledWith('session_gm_123');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user