Skip to content

Commit 13aa513

Browse files
committed
feat(admin): nav sequence and position logic
Adds NavEntry, buildNavSequence, NavPosition, getNavPosition, and step to gamemaster-utils.ts. All pure functions with no DB access. buildNavSequence(gameQuestions, rounds): Sorts GameQuestion[] by order field and maps each entry to a NavEntry carrying flatIndex, roundIdx, roundName, questionId, gameQuestionId, and questionStatus — pre-resolved so no component ever re-queries. NavPosition + getNavPosition(seq, flatIndex, prevRoundIdx): Resolves the full position: questionIdx within the current round, roundQuestions count, isFirst, isLast, and isRoundBoundary (true whenever roundIdx changes from prevRoundIdx; -1 = first call sentinel). step(seq, flatIndex, dir): Returns the next flat index clamped to [0, seq.length - 1]. Tests: 25 cases across buildNavSequence (sort order, round resolution, fallbacks, immutability) and getNavPosition + step (all boundary states, per-round questionIdx, forward and backward boundary detection).
1 parent 1aa98c4 commit 13aa513

2 files changed

Lines changed: 253 additions & 0 deletions

File tree

src/pages/admin/gamemaster-utils.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,98 @@ export function upsertPlayer(
7474
export function markPlayerAway(players: Player[], playerId: string): Player[] {
7575
return players.map(p => (p.id === playerId ? { ...p, isAway: true } : p))
7676
}
77+
78+
// ── Navigation types ──────────────────────────────────────────────────────────
79+
80+
/**
81+
* One entry in the flat navigation sequence — a single question slot with
82+
* its resolved round context pre-computed.
83+
*/
84+
export interface NavEntry {
85+
flatIndex: number // position across all questions in the game (0-based)
86+
roundIdx: number // which round this question belongs to (0-based)
87+
roundId: string
88+
roundName: string
89+
questionId: string
90+
gameQuestionId: string
91+
questionStatus: import('@/db').GameQuestion['status']
92+
}
93+
94+
// ── buildNavSequence ──────────────────────────────────────────────────────────
95+
96+
/**
97+
* Builds a flat ordered navigation sequence from gameQuestions and rounds.
98+
* GameQuestions are sorted by their `order` field. Each entry carries the
99+
* resolved round name and index so nav components never need to re-query.
100+
*
101+
* Pure function — no DB access.
102+
*/
103+
export function buildNavSequence(
104+
gameQuestions: import('@/db').GameQuestion[],
105+
rounds: import('@/db').Round[]
106+
): NavEntry[] {
107+
const roundIndex = new Map(rounds.map((r, i) => [r.id, { name: r.name, idx: i }]))
108+
const sorted = [...gameQuestions].sort((a, b) => a.order - b.order)
109+
110+
return sorted.map((gq, flatIndex) => {
111+
const round = roundIndex.get(gq.roundId)
112+
return {
113+
flatIndex,
114+
roundIdx: round?.idx ?? 0,
115+
roundId: gq.roundId,
116+
roundName: round?.name ?? 'Round',
117+
questionId: gq.questionId,
118+
gameQuestionId: gq.id,
119+
questionStatus: gq.status,
120+
}
121+
})
122+
}
123+
124+
// ── Navigation position ───────────────────────────────────────────────────────
125+
126+
export interface NavPosition {
127+
flatIndex: number
128+
roundIdx: number
129+
questionIdx: number // position within the current round (0-based)
130+
roundQuestions: number // total questions in current round
131+
isFirst: boolean
132+
isLast: boolean
133+
isRoundBoundary: boolean // true when this move crossed a round boundary
134+
}
135+
136+
/**
137+
* Computes the NavPosition for a given flat index within a sequence.
138+
* `prevRoundIdx` is needed to detect boundary crossings — pass the
139+
* round index of the *previous* position, or -1 on the first call.
140+
*
141+
* Pure function — no DB access.
142+
*/
143+
export function getNavPosition(
144+
seq: NavEntry[],
145+
flatIndex: number,
146+
prevRoundIdx: number
147+
): NavPosition {
148+
const entry = seq[flatIndex]
149+
const roundIdx = entry?.roundIdx ?? 0
150+
const roundId = entry?.roundId ?? ''
151+
const inRound = seq.filter(e => e.roundId === roundId)
152+
const questionIdx = inRound.findIndex(e => e.flatIndex === flatIndex)
153+
154+
return {
155+
flatIndex,
156+
roundIdx,
157+
questionIdx: questionIdx === -1 ? 0 : questionIdx,
158+
roundQuestions: inRound.length,
159+
isFirst: flatIndex === 0,
160+
isLast: flatIndex === seq.length - 1,
161+
isRoundBoundary: prevRoundIdx !== -1 && roundIdx !== prevRoundIdx,
162+
}
163+
}
164+
165+
/**
166+
* Returns the next flat index when moving forward (+1) or backward (-1),
167+
* clamped to [0, seq.length - 1].
168+
*/
169+
export function step(seq: NavEntry[], flatIndex: number, dir: 1 | -1): number {
170+
return Math.max(0, Math.min(seq.length - 1, flatIndex + dir))
171+
}

src/test/gamemaster-lobby.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,161 @@ describe('markPlayerAway', () => {
235235
expect(markPlayerAway([], 'p1')).toHaveLength(0)
236236
})
237237
})
238+
239+
// ── buildNavSequence ──────────────────────────────────────────────────────────
240+
241+
import { buildNavSequence } from '@/pages/admin/gamemaster-utils'
242+
import type { GameQuestion, Round } from '@/db'
243+
244+
const ROUNDS: Round[] = [
245+
{ id: 'r1', name: 'Round 1', description: '', questionIds: ['q1', 'q2'], createdAt: 0 },
246+
{ id: 'r2', name: 'Round 2', description: '', questionIds: ['q3'], createdAt: 0 },
247+
]
248+
249+
const GQS: GameQuestion[] = [
250+
{ id: 'gq1', gameId: 'g1', questionId: 'q1', roundId: 'r1', order: 0, status: 'pending' },
251+
{ id: 'gq2', gameId: 'g1', questionId: 'q2', roundId: 'r1', order: 1, status: 'correct' },
252+
{ id: 'gq3', gameId: 'g1', questionId: 'q3', roundId: 'r2', order: 2, status: 'pending' },
253+
]
254+
255+
describe('buildNavSequence', () => {
256+
it('returns one entry per game question', () => {
257+
expect(buildNavSequence(GQS, ROUNDS)).toHaveLength(3)
258+
})
259+
260+
it('assigns flat indices 0, 1, 2', () => {
261+
const seq = buildNavSequence(GQS, ROUNDS)
262+
expect(seq.map(e => e.flatIndex)).toEqual([0, 1, 2])
263+
})
264+
265+
it('resolves round name and index from rounds list', () => {
266+
const seq = buildNavSequence(GQS, ROUNDS)
267+
expect(seq[0].roundName).toBe('Round 1')
268+
expect(seq[0].roundIdx).toBe(0)
269+
expect(seq[2].roundName).toBe('Round 2')
270+
expect(seq[2].roundIdx).toBe(1)
271+
})
272+
273+
it('carries questionId and gameQuestionId', () => {
274+
const seq = buildNavSequence(GQS, ROUNDS)
275+
expect(seq[0].questionId).toBe('q1')
276+
expect(seq[0].gameQuestionId).toBe('gq1')
277+
})
278+
279+
it('carries question status', () => {
280+
const seq = buildNavSequence(GQS, ROUNDS)
281+
expect(seq[1].questionStatus).toBe('correct')
282+
})
283+
284+
it('sorts by order field regardless of input order', () => {
285+
const shuffled = [GQS[2], GQS[0], GQS[1]]
286+
const seq = buildNavSequence(shuffled, ROUNDS)
287+
expect(seq.map(e => e.questionId)).toEqual(['q1', 'q2', 'q3'])
288+
})
289+
290+
it('returns empty array for no questions', () => {
291+
expect(buildNavSequence([], ROUNDS)).toHaveLength(0)
292+
})
293+
294+
it('falls back round name to "Round" for unknown roundId', () => {
295+
const gqOrphan: GameQuestion = {
296+
id: 'gq9',
297+
gameId: 'g1',
298+
questionId: 'qX',
299+
roundId: 'unknown',
300+
order: 0,
301+
status: 'pending',
302+
}
303+
const seq = buildNavSequence([gqOrphan], ROUNDS)
304+
expect(seq[0].roundName).toBe('Round')
305+
expect(seq[0].roundIdx).toBe(0)
306+
})
307+
308+
it('does not mutate the input array', () => {
309+
const input = [...GQS]
310+
buildNavSequence([GQS[2], GQS[0]], ROUNDS)
311+
expect(input).toHaveLength(3)
312+
})
313+
})
314+
315+
// ── getNavPosition + step ─────────────────────────────────────────────────────
316+
317+
import { getNavPosition, step } from '@/pages/admin/gamemaster-utils'
318+
319+
// Reuse GQS + ROUNDS from above — 3 questions: r1(q1,q2), r2(q3)
320+
const SEQ = buildNavSequence(GQS, ROUNDS)
321+
322+
describe('getNavPosition', () => {
323+
it('first question: isFirst=true, isLast=false, roundIdx=0', () => {
324+
const pos = getNavPosition(SEQ, 0, -1)
325+
expect(pos.isFirst).toBe(true)
326+
expect(pos.isLast).toBe(false)
327+
expect(pos.roundIdx).toBe(0)
328+
})
329+
330+
it('last question: isLast=true', () => {
331+
const pos = getNavPosition(SEQ, 2, 1)
332+
expect(pos.isLast).toBe(true)
333+
expect(pos.isFirst).toBe(false)
334+
})
335+
336+
it('questionIdx is position within the round', () => {
337+
expect(getNavPosition(SEQ, 0, -1).questionIdx).toBe(0) // q1 is 1st in r1
338+
expect(getNavPosition(SEQ, 1, -1).questionIdx).toBe(1) // q2 is 2nd in r1
339+
expect(getNavPosition(SEQ, 2, -1).questionIdx).toBe(0) // q3 is 1st in r2
340+
})
341+
342+
it('roundQuestions reflects count in that round', () => {
343+
expect(getNavPosition(SEQ, 0, -1).roundQuestions).toBe(2) // r1 has 2
344+
expect(getNavPosition(SEQ, 2, -1).roundQuestions).toBe(1) // r2 has 1
345+
})
346+
347+
it('isRoundBoundary false on first call (prevRoundIdx = -1)', () => {
348+
expect(getNavPosition(SEQ, 0, -1).isRoundBoundary).toBe(false)
349+
})
350+
351+
it('isRoundBoundary false when staying in same round', () => {
352+
expect(getNavPosition(SEQ, 1, 0).isRoundBoundary).toBe(false) // r1 → r1
353+
})
354+
355+
it('isRoundBoundary true when crossing into next round', () => {
356+
expect(getNavPosition(SEQ, 2, 0).isRoundBoundary).toBe(true) // r1 → r2
357+
})
358+
359+
it('isRoundBoundary true when going backwards across a round', () => {
360+
// Moving from flatIndex=2 (r2) backward to flatIndex=1 (r1) — boundary crossed
361+
expect(getNavPosition(SEQ, 1, 1).isRoundBoundary).toBe(true)
362+
// Moving within r1 backward — no boundary
363+
expect(getNavPosition(SEQ, 0, 0).isRoundBoundary).toBe(false)
364+
})
365+
366+
it('returns flatIndex matching the requested index', () => {
367+
expect(getNavPosition(SEQ, 1, 0).flatIndex).toBe(1)
368+
})
369+
})
370+
371+
describe('step', () => {
372+
it('moves forward by 1', () => {
373+
expect(step(SEQ, 0, 1)).toBe(1)
374+
expect(step(SEQ, 1, 1)).toBe(2)
375+
})
376+
377+
it('moves backward by 1', () => {
378+
expect(step(SEQ, 2, -1)).toBe(1)
379+
expect(step(SEQ, 1, -1)).toBe(0)
380+
})
381+
382+
it('clamps at 0 when going back from first', () => {
383+
expect(step(SEQ, 0, -1)).toBe(0)
384+
})
385+
386+
it('clamps at last when going forward from last', () => {
387+
expect(step(SEQ, 2, 1)).toBe(2)
388+
})
389+
390+
it('handles single-question sequence', () => {
391+
const single = [SEQ[0]]
392+
expect(step(single, 0, 1)).toBe(0)
393+
expect(step(single, 0, -1)).toBe(0)
394+
})
395+
})

0 commit comments

Comments
 (0)