feat(FEAT-3): reslot on all participant mutation paths
Reslot (stable sort by init desc, tie-break = original array index) now
fires on all 4 paths that can change order:
1. Add participant — sortParticipantsByInitiative([...parts, new], parts)
2. Edit modal save (handleUpdateParticipant) — reslot + syncTurnOrder
3. Drag reorder — splice move (already correct, untouched)
4. Inline init field — reslot (already committed 08c27c1)
Before: add appended (ignored init), edit modal overwrote value without
moving slot. Both caused list order to drift from init order until
startEncounter (sorts once). Now any init change immediately reslots
into correct position. Display + AdminView reflect order.
Stable sort preserves drag order within ties (tie-break = original index
= reflects prior drag). Move-one semantics: only changed element moves.
EditParticipantModal: added htmlFor/id link on Initiative label (was
missing — a11y + testable).
Tests: ReslotAllPaths.test.js (2). RED first (add appended, edit modal
no reslot), green after impl.
This commit is contained in:
@@ -10,6 +10,7 @@ REWORK_PLAN.md.
|
||||
|
||||
### feat - campaign section rollup
|
||||
|
||||
### feat - add all characters to participants list
|
||||
|
||||
### FEAT-M6: Transactional undo (moved from REWORK_PLAN)
|
||||
- Every mutating action writes event: `(type, payload, undo_payload, undone, ts)`.
|
||||
|
||||
+9
-3
@@ -444,9 +444,10 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-300">Initiative</label>
|
||||
<label htmlFor="edit-initiative" className="block text-sm font-medium text-stone-300">Initiative</label>
|
||||
<input
|
||||
type="number"
|
||||
id="edit-initiative"
|
||||
value={initiative}
|
||||
onChange={(e) => setInitiative(e.target.value)}
|
||||
className="mt-1 block 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"
|
||||
@@ -857,7 +858,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
||||
|
||||
try {
|
||||
await storage.updateDoc(encounterPath, {
|
||||
participants: [...participants, newParticipant]
|
||||
participants: sortParticipantsByInitiative([...participants, newParticipant], participants),
|
||||
...syncTurnOrder([...participants, newParticipant]),
|
||||
});
|
||||
logAction(`${nameToAdd} added to encounter (Initiative: ${computedInitiative})`, { encounterName: encounter.name }, {
|
||||
encounterPath,
|
||||
@@ -939,9 +941,13 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
||||
const updatedParticipants = participants.map(p =>
|
||||
p.id === editingParticipant.id ? { ...p, ...updatedData } : p
|
||||
);
|
||||
const reslotted = sortParticipantsByInitiative(updatedParticipants, participants);
|
||||
|
||||
try {
|
||||
await storage.updateDoc(encounterPath, { participants: updatedParticipants });
|
||||
await storage.updateDoc(encounterPath, {
|
||||
participants: reslotted,
|
||||
...syncTurnOrder(reslotted),
|
||||
});
|
||||
setEditingParticipant(null);
|
||||
} catch (err) {
|
||||
console.error("Error updating participant:", err);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// RED: reslot must fire on ALL 4 participant-mutation paths.
|
||||
// Path 1 add, path 2 edit modal, path 3 drag (already correct), path 4 inline field (already correct).
|
||||
// Tests add + edit modal reslot. Drag + inline already covered.
|
||||
import React from 'react';
|
||||
import { screen, waitFor, within, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, addMonsterViaUI } from './testHelpers';
|
||||
import { getCalls } from '../__mocks__/firebase/_mock-db';
|
||||
|
||||
function lastParticipantsUpdate() {
|
||||
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
const last = calls[calls.length - 1];
|
||||
return last && last.data.participants;
|
||||
}
|
||||
|
||||
async function addOne(form, name, hp, mod, init) {
|
||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
|
||||
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } });
|
||||
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(hp) } });
|
||||
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
await waitFor(() => {
|
||||
const parts = lastParticipantsUpdate();
|
||||
if (!parts || !parts.some(p => p.name === name)) throw new Error('not added');
|
||||
});
|
||||
}
|
||||
|
||||
describe('reslot on all mutation paths', () => {
|
||||
test('add inserts at correct init position (not append)', async () => {
|
||||
await renderApp();
|
||||
await createCampaignViaUI('Camp');
|
||||
await selectCampaignByName('Camp');
|
||||
await createEncounterViaUI('Enc');
|
||||
await selectEncounterByName('Enc');
|
||||
|
||||
const form = within(getParticipantForm());
|
||||
// add Orc(5) first, then Goblin(8) — Goblin should slot ABOVE Orc, not append below
|
||||
await addOne(form, 'Orc', 15, 0, 5);
|
||||
await addOne(form, 'Goblin', 7, 2, 8);
|
||||
|
||||
const parts = lastParticipantsUpdate();
|
||||
expect(parts.map(p => p.name)).toEqual(['Goblin', 'Orc']);
|
||||
});
|
||||
|
||||
test('edit modal init change reslots participant', async () => {
|
||||
await renderApp();
|
||||
await createCampaignViaUI('Camp2');
|
||||
await selectCampaignByName('Camp2');
|
||||
await createEncounterViaUI('Enc2');
|
||||
await selectEncounterByName('Enc2');
|
||||
|
||||
const form = within(getParticipantForm());
|
||||
await addOne(form, 'Orc', 15, 0, 5);
|
||||
await addOne(form, 'Goblin', 7, 2, 3);
|
||||
|
||||
// pre: Orc(5) before Goblin(3)
|
||||
expect(lastParticipantsUpdate().map(p => p.name)).toEqual(['Orc', 'Goblin']);
|
||||
|
||||
// open edit modal for Goblin, bump init to 8
|
||||
const editBtns = screen.getAllByTitle('Edit');
|
||||
const goblinEdit = editBtns.find(b => b.closest('li')?.textContent.includes('Goblin'));
|
||||
fireEvent.click(goblinEdit);
|
||||
|
||||
await waitFor(() => screen.getByText(`Edit Goblin`));
|
||||
// modal renders after row inputs; take last Initiative-labeled input
|
||||
const initInputs = screen.getAllByLabelText('Initiative');
|
||||
fireEvent.change(initInputs[initInputs.length - 1], { target: { value: '8' } });
|
||||
const saveBtns = screen.getAllByRole('button', { name: /Save/i });
|
||||
fireEvent.click(saveBtns[saveBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
const parts = lastParticipantsUpdate();
|
||||
expect(parts.map(p => p.name)).toEqual(['Goblin', 'Orc']);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user