Compare commits
2
Commits
2dfa155469
...
4a92c667c5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a92c667c5 | ||
|
|
6f11edab94 |
@@ -5,6 +5,23 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
||||
## Open
|
||||
|
||||
|
||||
fullscreen and dont lock on main app dm view and the no-game-player view
|
||||
|
||||
also better vert tab layout - labelt friendly
|
||||
|
||||
needs AC for players dude
|
||||
|
||||
and quick entry hp
|
||||
|
||||
hp do not carry from encounter to ecnounter!!!
|
||||
|
||||
|
||||
hp wont go over max and no temp hp support
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### dm list - keep active particpant in view (scroll)
|
||||
not sure good way to do this
|
||||
@@ -14,6 +31,11 @@ not sure good way to do this
|
||||
lots of updates
|
||||
|
||||
|
||||
## monsters per campaign and npcs
|
||||
## or/and ....copy from encoubnter?
|
||||
|
||||
|
||||
|
||||
|
||||
### TEST GAP: current branch changes need focused coverage
|
||||
- Storage `where()` contract: firebase + server adapters should honor `[where('encounterPath','==',x), orderBy('ts','desc'), limit(n)]`.
|
||||
|
||||
@@ -28,6 +28,7 @@ fi
|
||||
# frontend: server storage, :3999
|
||||
if ! lsof -ti :3999 >/dev/null 2>&1; then
|
||||
echo "starting frontend :3999..."
|
||||
NODE_ENV=development REACT_APP_DEV_TOOLS=1 \
|
||||
REACT_APP_STORAGE=server \
|
||||
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
|
||||
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// HP formula roll: parse "2d6+9" style, roll, return total.
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { rollHpFormula } = shared;
|
||||
|
||||
describe('rollHpFormula', () => {
|
||||
test('basic NdM', () => {
|
||||
const r = rollHpFormula('2d6');
|
||||
expect(r).toBeGreaterThanOrEqual(2);
|
||||
expect(r).toBeLessThanOrEqual(12);
|
||||
});
|
||||
|
||||
test('NdM+bonus', () => {
|
||||
const r = rollHpFormula('2d6+9');
|
||||
expect(r).toBeGreaterThanOrEqual(11);
|
||||
expect(r).toBeLessThanOrEqual(21);
|
||||
});
|
||||
|
||||
test('NdM-bonus', () => {
|
||||
const r = rollHpFormula('3d8-2');
|
||||
expect(r).toBeGreaterThanOrEqual(1);
|
||||
expect(r).toBeLessThanOrEqual(22);
|
||||
});
|
||||
|
||||
test('single die', () => {
|
||||
const r = rollHpFormula('1d20');
|
||||
expect(r).toBeGreaterThanOrEqual(1);
|
||||
expect(r).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
test('case insensitive', () => {
|
||||
const r = rollHpFormula('2D6+5');
|
||||
expect(r).toBeGreaterThanOrEqual(7);
|
||||
expect(r).toBeLessThanOrEqual(17);
|
||||
});
|
||||
|
||||
test('whitespace tolerant', () => {
|
||||
const r = rollHpFormula(' 2d6 + 9 ');
|
||||
expect(r).toBeGreaterThanOrEqual(11);
|
||||
expect(r).toBeLessThanOrEqual(21);
|
||||
});
|
||||
|
||||
test('large die', () => {
|
||||
const r = rollHpFormula('4d12+10');
|
||||
expect(r).toBeGreaterThanOrEqual(14);
|
||||
expect(r).toBeLessThanOrEqual(58);
|
||||
});
|
||||
|
||||
test('invalid returns null', () => {
|
||||
expect(rollHpFormula('abc')).toBeNull();
|
||||
expect(rollHpFormula('')).toBeNull();
|
||||
expect(rollHpFormula(null)).toBeNull();
|
||||
expect(rollHpFormula(undefined)).toBeNull();
|
||||
expect(rollHpFormula('2d')).toBeNull();
|
||||
expect(rollHpFormula('d6')).toBeNull();
|
||||
});
|
||||
|
||||
test('zero dice invalid', () => {
|
||||
expect(rollHpFormula('0d6')).toBeNull();
|
||||
});
|
||||
});
|
||||
+33
-4
@@ -28,6 +28,22 @@ const generateId = () =>
|
||||
|
||||
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
|
||||
|
||||
// Parse HP formula like "2d6+9" or "3d8-2" or "1d20". Roll, return total.
|
||||
// Returns null on invalid input. Whitespace/case tolerant.
|
||||
function rollHpFormula(str) {
|
||||
if (!str || typeof str !== 'string') return null;
|
||||
const m = str.trim().toLowerCase().match(/^(\d+)d(\d+)\s*([+-]\s*\d+)?$/);
|
||||
if (!m) return null;
|
||||
const count = parseInt(m[1], 10);
|
||||
const sides = parseInt(m[2], 10);
|
||||
if (count === 0 || sides === 0) return null;
|
||||
let bonus = 0;
|
||||
if (m[3]) bonus = parseInt(m[3].replace(/\s/g, ''), 10);
|
||||
let total = 0;
|
||||
for (let i = 0; i < count; i++) total += Math.floor(Math.random() * sides) + 1;
|
||||
return total + bonus;
|
||||
}
|
||||
|
||||
const formatInitMod = (mod) => {
|
||||
if (mod === undefined || mod === null) return 'N/A';
|
||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||
@@ -344,6 +360,7 @@ function makeParticipant(opts) {
|
||||
status: opts.status || (opts.currentHp === 0 ? 'dying' : 'conscious'),
|
||||
deathSaveSuccesses: opts.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: opts.deathSaveFailures || 0,
|
||||
hpFormula: opts.hpFormula || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -365,11 +382,22 @@ function buildCharacterParticipant(character) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildMonsterParticipant({ name, maxHp, initMod, asNpc }) {
|
||||
function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula }) {
|
||||
const initiativeRoll = rollD20();
|
||||
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
|
||||
const finalInitiative = initiativeRoll + modifier;
|
||||
const hp = maxHp || DEFAULT_MAX_HP;
|
||||
// maxHp wins if both set; else roll formula; else default.
|
||||
let hp;
|
||||
let hpRoll = null;
|
||||
if (maxHp) {
|
||||
hp = maxHp;
|
||||
} else if (hpFormula) {
|
||||
const rolled = rollHpFormula(hpFormula);
|
||||
hp = rolled || DEFAULT_MAX_HP;
|
||||
hpRoll = rolled;
|
||||
} else {
|
||||
hp = DEFAULT_MAX_HP;
|
||||
}
|
||||
return {
|
||||
participant: makeParticipant({
|
||||
name,
|
||||
@@ -377,8 +405,9 @@ function buildMonsterParticipant({ name, maxHp, initMod, asNpc }) {
|
||||
initiative: finalInitiative,
|
||||
maxHp: hp,
|
||||
currentHp: hp,
|
||||
hpFormula: hpFormula || null,
|
||||
}),
|
||||
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
|
||||
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative, hpRoll },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1044,7 +1073,7 @@ async function toggleHidePlayerHp(currentValue, ctx) {
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD,
|
||||
generateId, rollD20, formatInitMod,
|
||||
generateId, rollD20, rollHpFormula, formatInitMod,
|
||||
sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval,
|
||||
snapshotOf, buildEntry, expandUndo,
|
||||
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
||||
|
||||
+84
-25
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useRef, useMemo, createContext, useContext, useCallback } from 'react';
|
||||
import * as shared from '@ttrpg/shared';
|
||||
import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, where, orderBy, limit, offset, getStorage, getStorageMode } from './storage';
|
||||
import { isDevToolsEnabled } from './config/devTools';
|
||||
import {
|
||||
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
|
||||
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
|
||||
@@ -581,15 +582,26 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
||||
const [initiative, setInitiative] = useState(participant.initiative);
|
||||
const [currentHp, setCurrentHp] = useState(participant.currentHp);
|
||||
const [maxHp, setMaxHp] = useState(participant.maxHp);
|
||||
const [hpFormula, setHpFormula] = useState(participant.hpFormula || '');
|
||||
const [asNpc, setAsNpc] = useState(participant.type === 'npc');
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
let finalMaxHp = parseInt(maxHp, 10);
|
||||
// formula rolls only if maxHp empty. maxHp wins if both.
|
||||
if (isNaN(finalMaxHp) && hpFormula.trim()) {
|
||||
const rolled = shared.rollHpFormula(hpFormula);
|
||||
if (rolled) finalMaxHp = rolled;
|
||||
}
|
||||
if (isNaN(finalMaxHp)) finalMaxHp = participant.maxHp;
|
||||
let finalCurrentHp = parseInt(currentHp, 10);
|
||||
if (isNaN(finalCurrentHp)) finalCurrentHp = finalMaxHp;
|
||||
onSave({
|
||||
name: name.trim(),
|
||||
initiative: parseInt(initiative, 10),
|
||||
currentHp: parseInt(currentHp, 10),
|
||||
maxHp: parseInt(maxHp, 10),
|
||||
currentHp: finalCurrentHp,
|
||||
maxHp: finalMaxHp,
|
||||
hpFormula: (participant.type === 'monster' || participant.type === 'npc') ? (hpFormula.trim() || null) : null,
|
||||
type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type,
|
||||
});
|
||||
};
|
||||
@@ -636,6 +648,32 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(participant.type === 'monster' || participant.type === 'npc') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-300 mb-1">HP Formula</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={hpFormula}
|
||||
onChange={(e) => setHpFormula(e.target.value)}
|
||||
placeholder="2d6+9"
|
||||
className="flex-1 px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!shared.rollHpFormula(hpFormula)}
|
||||
onClick={() => {
|
||||
const rolled = shared.rollHpFormula(hpFormula);
|
||||
if (rolled) setMaxHp(rolled);
|
||||
}}
|
||||
className="px-3 py-2 text-sm font-medium text-white bg-violet-700 hover:bg-violet-800 rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Roll formula, set Max HP"
|
||||
>
|
||||
<Dices size={16} className="inline" /> Reroll
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(participant.type === 'monster' || participant.type === 'npc') && (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
@@ -972,7 +1010,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
const [participantType, setParticipantType] = useState('monster');
|
||||
const [selectedCharacterId, setSelectedCharacterId] = useState('');
|
||||
const [monsterInitMod, setMonsterInitMod] = useState(MONSTER_DEFAULT_INIT_MOD);
|
||||
const [maxHp, setMaxHp] = useState(DEFAULT_MAX_HP);
|
||||
const [maxHp, setMaxHp] = useState('');
|
||||
const [hpFormula, setHpFormula] = useState('');
|
||||
const [manualInitiative, setManualInitiative] = useState('');
|
||||
const [asNpc, setAsNpc] = useState(false);
|
||||
const [editingParticipant, setEditingParticipant] = useState(null);
|
||||
@@ -1002,11 +1041,11 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
if (selectedChar && selectedChar.defaultMaxHp) {
|
||||
setMaxHp(selectedChar.defaultMaxHp);
|
||||
} else {
|
||||
setMaxHp(DEFAULT_MAX_HP);
|
||||
setMaxHp('');
|
||||
}
|
||||
setAsNpc(false);
|
||||
} else if (participantType === 'monster') {
|
||||
setMaxHp(DEFAULT_MAX_HP);
|
||||
setMaxHp('');
|
||||
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
|
||||
}
|
||||
}, [selectedCharacterId, participantType, campaignCharacters]);
|
||||
@@ -1039,6 +1078,11 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
} else {
|
||||
modifier = parseInt(monsterInitMod, 10) || 0;
|
||||
participantAsNpc = asNpc;
|
||||
// maxHp wins if both set; else roll formula
|
||||
if (!maxHp && hpFormula.trim()) {
|
||||
const rolled = shared.rollHpFormula(hpFormula);
|
||||
if (rolled) currentMaxHp = rolled;
|
||||
}
|
||||
}
|
||||
|
||||
const computedInitiative = manualInit ? finalInitiative : (initiativeRoll + modifier);
|
||||
@@ -1050,6 +1094,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
initiative: computedInitiative,
|
||||
maxHp: currentMaxHp,
|
||||
currentHp: currentMaxHp,
|
||||
hpFormula: participantType === 'monster' ? (hpFormula.trim() || null) : null,
|
||||
conditions: [],
|
||||
isActive: true,
|
||||
status: 'conscious',
|
||||
@@ -1071,7 +1116,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION);
|
||||
|
||||
setParticipantName('');
|
||||
setMaxHp(DEFAULT_MAX_HP);
|
||||
setMaxHp('');
|
||||
setHpFormula('');
|
||||
setSelectedCharacterId('');
|
||||
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
|
||||
setAsNpc(false);
|
||||
@@ -1332,7 +1378,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
e.preventDefault();
|
||||
handleAddParticipant();
|
||||
}}
|
||||
className="grid grid-cols-1 md:grid-cols-6 gap-x-4 gap-y-2 mb-4 p-3 bg-stone-800 rounded items-end"
|
||||
className="grid grid-cols-1 md:grid-cols-12 gap-x-4 gap-y-2 mb-4 p-3 bg-stone-800 rounded items-end"
|
||||
>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-stone-300">Type</label>
|
||||
@@ -1352,7 +1398,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
|
||||
{participantType === 'monster' ? (
|
||||
<>
|
||||
<div className="md:col-span-4">
|
||||
<div className="md:col-span-10">
|
||||
<label htmlFor="monsterName" className="block text-sm font-medium text-stone-300">
|
||||
Monster Name
|
||||
</label>
|
||||
@@ -1365,7 +1411,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<div className="md:col-span-3">
|
||||
<label htmlFor="monsterInitMod" className="block text-sm font-medium text-stone-300">
|
||||
Init Mod
|
||||
</label>
|
||||
@@ -1377,7 +1423,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<div className="md:col-span-3">
|
||||
<label htmlFor="manualInitiative" className="block text-sm font-medium text-stone-300">
|
||||
Initiative <span className="text-xs text-stone-400">(blank=roll)</span>
|
||||
</label>
|
||||
@@ -1390,7 +1436,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<div className="md:col-span-3">
|
||||
<label htmlFor="monsterMaxHp" className="block text-sm font-medium text-stone-300">
|
||||
Max HP
|
||||
</label>
|
||||
@@ -1402,7 +1448,20 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 flex items-center pt-5">
|
||||
<div className="md:col-span-3">
|
||||
<label htmlFor="monsterHpFormula" className="block text-sm font-medium text-stone-300">
|
||||
HP Formula (e.g. 2d6+9)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="monsterHpFormula"
|
||||
value={hpFormula}
|
||||
onChange={(e) => setHpFormula(e.target.value)}
|
||||
placeholder="2d6+9"
|
||||
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-12 flex items-center pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="asNpc"
|
||||
@@ -1457,7 +1516,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="md:col-span-6 flex justify-end mt-2">
|
||||
<div className="md:col-span-12 flex justify-end mt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={encounter.isStarted && !encounter.isPaused}
|
||||
@@ -2696,6 +2755,18 @@ function AdminView({ userId }) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isDevToolsEnabled() && campaignsWithDetails.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => setShowDeleteAllConfirm(true)}
|
||||
className="px-3 py-1.5 text-xs rounded bg-red-900 hover:bg-red-800 text-red-100 border border-red-700"
|
||||
title="DEV ONLY: Delete every campaign, encounter, and log. Irreversible."
|
||||
>
|
||||
<Trash2 size={12} className="inline mr-1" />DEV: Delete All Campaigns
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -2742,18 +2813,6 @@ function AdminView({ userId }) {
|
||||
message={`Are you sure you want to delete the campaign "${itemToDelete?.name}" and all its encounters? This action cannot be undone.`}
|
||||
/>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && campaignsWithDetails.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => setShowDeleteAllConfirm(true)}
|
||||
className="px-3 py-1.5 text-xs rounded bg-red-900 hover:bg-red-800 text-red-100 border border-red-700"
|
||||
title="DEV ONLY: Delete every campaign, encounter, and log. Irreversible."
|
||||
>
|
||||
<Trash2 size={12} className="inline mr-1" />DEV: Delete All Campaigns
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={showDeleteAllConfirm}
|
||||
onClose={() => setShowDeleteAllConfirm(false)}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Dev-tools gate. Explicit opt-in via REACT_APP_DEV_TOOLS=1.
|
||||
// Safe default: any value other than exactly '1' = off.
|
||||
// Dynamic key access so react-scripts DefinePlugin does NOT inline the value
|
||||
// at build time — allows tests + runtime env changes to take effect.
|
||||
const KEY = 'REACT_APP_' + 'DEV_TOOLS';
|
||||
export const isDevToolsEnabled = () => process.env[KEY] === '1';
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isDevToolsEnabled } from '../config/devTools';
|
||||
|
||||
describe('Bulk-delete-all dev-tools gate logic', () => {
|
||||
const orig = process.env.REACT_APP_DEV_TOOLS;
|
||||
|
||||
afterEach(() => {
|
||||
if (orig === undefined) delete process.env.REACT_APP_DEV_TOOLS;
|
||||
else process.env.REACT_APP_DEV_TOOLS = orig;
|
||||
});
|
||||
|
||||
test('false when unset (safe default)', () => {
|
||||
delete process.env.REACT_APP_DEV_TOOLS;
|
||||
expect(isDevToolsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
test('false when 0', () => {
|
||||
process.env.REACT_APP_DEV_TOOLS = '0';
|
||||
expect(isDevToolsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
test('false when arbitrary string', () => {
|
||||
process.env.REACT_APP_DEV_TOOLS = 'true';
|
||||
expect(isDevToolsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
test('true only when exactly 1', () => {
|
||||
process.env.REACT_APP_DEV_TOOLS = '1';
|
||||
expect(isDevToolsEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, fireEvent, cleanup, act } from '@testing-library/react';
|
||||
import App from '../App';
|
||||
|
||||
describe('Bulk-delete-all button render gating', () => {
|
||||
const orig = process.env.REACT_APP_DEV_TOOLS;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (orig === undefined) delete process.env.REACT_APP_DEV_TOOLS;
|
||||
else process.env.REACT_APP_DEV_TOOLS = orig;
|
||||
});
|
||||
|
||||
async function renderAndCreateCampaign() {
|
||||
window.history.replaceState({}, '', '/');
|
||||
global.alert = jest.fn();
|
||||
global.window.open = jest.fn();
|
||||
render(<App />);
|
||||
await waitFor(() => screen.getByRole('button', { name: /Create Campaign/i }));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i }));
|
||||
});
|
||||
await waitFor(() => screen.getByLabelText(/Campaign Name/i));
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'Gate Render Test' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
});
|
||||
}
|
||||
|
||||
test('prod safety: button absent when DEV_TOOLS unset', async () => {
|
||||
delete process.env.REACT_APP_DEV_TOOLS;
|
||||
await renderAndCreateCampaign();
|
||||
expect(screen.queryByText(/Delete All Campaigns/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, fireEvent, cleanup, act } from '@testing-library/react';
|
||||
import App from '../App';
|
||||
|
||||
describe('Bulk-delete-all button dev feature', () => {
|
||||
const orig = process.env.REACT_APP_DEV_TOOLS;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (orig === undefined) delete process.env.REACT_APP_DEV_TOOLS;
|
||||
else process.env.REACT_APP_DEV_TOOLS = orig;
|
||||
});
|
||||
|
||||
async function renderAndCreateCampaign() {
|
||||
window.history.replaceState({}, '', '/');
|
||||
global.alert = jest.fn();
|
||||
global.window.open = jest.fn();
|
||||
render(<App />);
|
||||
await waitFor(() => screen.getByRole('button', { name: /Create Campaign/i }));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i }));
|
||||
});
|
||||
await waitFor(() => screen.getByLabelText(/Campaign Name/i));
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'Gate Render Test' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
});
|
||||
}
|
||||
|
||||
test('dev: button present when DEV_TOOLS=1 and campaigns exist', async () => {
|
||||
process.env.REACT_APP_DEV_TOOLS = '1';
|
||||
await renderAndCreateCampaign();
|
||||
await waitFor(() => screen.getByText(/Delete All Campaigns/i), { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user