Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@univerlab/quorum",
"version": "0.1.6",
"version": "0.1.7",
"description": "Browser-native planning poker — P2P, no accounts, no server",
"license": "MIT",
"type": "module",
Expand Down
108 changes: 106 additions & 2 deletions src/__tests__/protocol.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -486,9 +486,11 @@ describe('protocol — stories list', () => {
expect(s.currentIndex).toBe(-1);
});

it('loadStories broadcasts stories-load event', () => {
it('loadStories broadcasts stories-load event with the IDs', () => {
proto.loadStories('X\nY');
expect(mockRoom.getSend('stories-load')).toHaveBeenCalledWith({ stories: ['X', 'Y'] });
const [[payload]] = mockRoom.getSend('stories-load').mock.calls;
expect(payload.stories).toEqual(['X', 'Y']);
expect(payload.storyIds).toEqual(latest(updates).storyIds);
});

it('incoming stories-load updates list', () => {
Expand All @@ -498,6 +500,108 @@ describe('protocol — stories list', () => {
});
});

// ── Story IDs ─────────────────────────────────────────────────────────────────
//
// Results are keyed by story ID, so an ID that goes missing or slips out of
// alignment silently attaches a round's outcome to the wrong story — or throws
// on a list that is `undefined`.

describe('protocol — story IDs', () => {
let proto, updates;

beforeEach(async () => {
vi.useFakeTimers();
vi.resetModules();
({ proto, updates } = await makeProtocol());
vi.advanceTimersByTime(1);
});

afterEach(() => { proto.destroy(); vi.useRealTimers(); });

it('keeps one ID per story', () => {
proto.loadStories('A\nB\nC');
const s = latest(updates);
expect(s.storyIds).toHaveLength(3);
expect(new Set(s.storyIds).size).toBe(3);
});

it('derives storyId from the story being voted on', () => {
proto.loadStories('A\nB');
const { storyIds } = latest(updates);
proto.startVoting('B');
expect(latest(updates).storyId).toBe(storyIds[1]);
});

it('follows the story across nextStory, which never set storyId', () => {
proto.loadStories('A\nB');
const { storyIds } = latest(updates);
proto.startVoting('A');
proto.nextStory();
expect(latest(updates).storyId).toBe(storyIds[1]);
});

it('reports no storyId while no story is selected', () => {
expect(latest(updates).storyId).toBeNull();
});

it('adopts the IDs carried by a stories-load', () => {
mockRoom.triggerAction('stories-load', {
stories: ['Remote A', 'Remote B'],
storyIds: ['id-a', 'id-b'],
});
expect(latest(updates).storyIds).toEqual(['id-a', 'id-b']);
});

it('survives a state-sync — the regression: storyIds arrived undefined', () => {
mockRoom.triggerAction('state-sync', {
state: {
roomId: 'ROOM-TEST',
stories: ['Auth flow', 'Billing'],
storyIds: ['id-auth', 'id-billing'],
currentIndex: 1,
storyTitle: 'Billing',
phase: 'voting',
votes: {},
participants: { 'u9': mkPeer('u9', 'Zoe') },
roundId: 'r1',
autoReveal: true,
},
});
const s = latest(updates);
expect(s.storyIds).toEqual(['id-auth', 'id-billing']);
expect(s.storyId).toBe('id-billing');
// Every path that indexes storyIds used to throw on `undefined`.
expect(() => proto.startVoting('Auth flow')).not.toThrow();
expect(latest(updates).storyId).toBe('id-auth');
});

it('fills in IDs a peer omitted rather than leaving the arrays ragged', () => {
mockRoom.triggerAction('stories-load', { stories: ['A', 'B', 'C'] });
const s = latest(updates);
expect(s.storyIds).toHaveLength(3);
expect(s.storyIds.every(id => typeof id === 'string' && id.length > 0)).toBe(true);
});

it('replaces an unusable ID in place, keeping later stories aligned', () => {
mockRoom.triggerAction('stories-load', {
stories: ['A', 'B', 'C'],
storyIds: ['id-a', 42, 'id-c'],
});
const s = latest(updates);
expect(s.storyIds).toHaveLength(3);
expect(s.storyIds[0]).toBe('id-a');
expect(s.storyIds[2]).toBe('id-c');
});

it('truncates IDs a peer sent in excess of the stories', () => {
mockRoom.triggerAction('stories-load', {
stories: ['A'],
storyIds: ['id-a', 'id-b', 'id-c'],
});
expect(latest(updates).storyIds).toEqual(['id-a']);
});
});

// ── Payload validation (untrusted peers) ────────────────────────────────────────

describe('protocol — payload validation', () => {
Expand Down
3 changes: 3 additions & 0 deletions src/pages/room.js
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,9 @@ function loadStoriesFromStorage(roomId) {
}

function saveResultToStorage(roomId, storyId, storyTitle, votes) {
// Results are looked up by story ID. A round with no story behind it — voting
// started before any list was loaded — would write an entry nothing can read.
if (!storyId) return;
const key = `quorum-results-${roomId}`;
const existing = JSON.parse(localStorage.getItem(key) || '[]');
const stats = computeStats(votes);
Expand Down
26 changes: 21 additions & 5 deletions src/protocol.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ const cleanStories = (arr) =>
: [];

const isValidId = (id) => typeof id === 'string' && id.length > 0 && id.length <= MAX_ID_LEN;

// Story IDs index `stories` positionally, so the two arrays must stay the same
// length whatever a peer sends. Anything unusable is replaced, never dropped —
// dropping would shift every later ID onto the wrong story.
const cleanStoryIds = (arr, count) => {
const src = Array.isArray(arr) ? arr : [];
return Array.from({ length: count }, (_, i) =>
isValidId(src[i]) ? src[i] : crypto.randomUUID());
};

const isCard = (c) => c == null || DECK.includes(c);

/**
Expand Down Expand Up @@ -68,6 +78,9 @@ export function createProtocol(roomId, userId, userName, onUpdate, onCountdown)
...state,
participants: { ...state.participants },
votes: { ...state.votes },
// Derived, never stored: whichever story currentIndex points at. Kept as
// a field only because consumers read it to key saved results.
storyId: state.storyIds[state.currentIndex] ?? null,
};
}

Expand Down Expand Up @@ -174,9 +187,11 @@ export function createProtocol(roomId, userId, userName, onUpdate, onCountdown)
for (const id of Object.keys(participants)) {
votes[id] = isCard(incoming.votes[id]) ? incoming.votes[id] ?? null : null;
}
const stories = cleanStories(incoming.stories);
state = {
roomId,
stories: cleanStories(incoming.stories),
stories,
storyIds: cleanStoryIds(incoming.storyIds, stories.length),
currentIndex: Number.isInteger(incoming.currentIndex) ? incoming.currentIndex : -1,
storyTitle: typeof incoming.storyTitle === 'string' ? incoming.storyTitle.slice(0, MAX_TITLE_LEN) : '',
phase: VALID_PHASES.includes(incoming.phase) ? incoming.phase : 'waiting',
Expand Down Expand Up @@ -251,6 +266,9 @@ export function createProtocol(roomId, userId, userName, onUpdate, onCountdown)
const stories = isObj(data) ? data.stories : null;
if (!Array.isArray(stories)) return;
state.stories = cleanStories(stories);
// The IDs travel with the stories they belong to. Rebuilding them locally
// would leave every peer keying results by a different ID.
state.storyIds = cleanStoryIds(data.storyIds, state.stories.length);
state.currentIndex = -1;
emit();
});
Expand Down Expand Up @@ -324,9 +342,7 @@ export function createProtocol(roomId, userId, userName, onUpdate, onCountdown)
state.storyTitle = title;
state.phase = 'voting';
state.roundId = newRoundId;
const idx = state.stories.indexOf(title);
state.currentIndex = idx;
state.storyId = idx >= 0 ? state.storyIds[idx] : null;
state.currentIndex = state.stories.indexOf(title);
revealPending = false;
clearVotes();
sendNewStory({ index: state.currentIndex, title, roundId: newRoundId, phase: 'voting' });
Expand Down Expand Up @@ -418,7 +434,7 @@ export function createProtocol(roomId, userId, userName, onUpdate, onCountdown)
} else {
state.currentIndex = -1;
}
sendStoriesLoad({ stories });
sendStoriesLoad({ stories, storyIds: newIds });
emit();
},

Expand Down
4 changes: 3 additions & 1 deletion src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ export function createInitialState(roomId) {
return {
roomId,
stories: [],
// Parallel to `stories`, same length: storyIds[i] identifies stories[i].
// `storyId` is not stored — it is derived from currentIndex on read, so it
// can never drift out of step with the story actually being voted on.
storyIds: [],
currentIndex: -1,
storyTitle: '',
storyId: null,
phase: 'waiting',
votes: {},
participants: {},
Expand Down
Loading