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
18 changes: 18 additions & 0 deletions apps/web/src/features/coach/components/LessonPlayer.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,22 @@ describe('LessonPlayer', () => {
render(<LessonPlayer lessonId='does-not-exist' />)
expect(screen.getByText('Leçon introuvable')).toBeDefined()
})

it('should link the last step of an intermediate chapter to the next chapter in journey order', async () => {
const user = userEvent.setup()
render(<LessonPlayer lessonId='white-cross' />)
await user.click(screen.getByLabelText(/^Étape 5 :/))

const next = screen.getByRole('link', { name: 'Chapitre suivant' })
expect(next.getAttribute('href')).toBe('/coach/white-corners')
})

it('should link the last step of the final chapter back to the Coach index', async () => {
const user = userEvent.setup()
render(<LessonPlayer lessonId='finish' />)
await user.click(screen.getByLabelText(/^Étape 3 :/))

const back = screen.getByRole('link', { name: 'Retour au Coach' })
expect(back.getAttribute('href')).toBe('/coach')
})
})
11 changes: 11 additions & 0 deletions apps/web/src/features/coach/data/illustrative.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect, it } from 'bun:test'

import {
secondLayerState,
warmMilestones,
whiteCornersState,
whiteCrossOnlyState,
yellowCrossState,
Expand Down Expand Up @@ -87,3 +88,13 @@ describe('production milestones are genuine, reachable partial states (SUG-1)',
expect(D_CORNERS.every(p => cornerHome(s, p))).toBe(true)
})
})

describe('warmMilestones (#17 idle prewarm)', () => {
it('primes the milestone cache without throwing, idempotently', () => {
expect(() => warmMilestones()).not.toThrow()
// a second call is a cache hit — still safe, no recompute side effects
expect(() => warmMilestones()).not.toThrow()
// states are genuine and available once warmed
expect(twoLayersComplete(secondLayerState())).toBe(true)
})
})
8 changes: 8 additions & 0 deletions apps/web/src/features/coach/data/illustrative.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@ const computeMilestones = (): Milestones => {

const milestones = (): Milestones => (cache ??= compute())

// Eagerly fill the memoised cache. The first milestone access runs solveCube on the
// fixed scramble (~420ms on the main thread); calling this from the Coach route during
// idle time means the first lesson's first demo hits a warm cache instead of paying
// that cost mid-click (#17). Idempotent — a second call is a cheap cache hit.
export const warmMilestones = (): void => {
milestones()
}

// CubeState milestones — the store builds a case demo as applyMoves(milestone,
// invertMoves(alg)), so it needs the state, not just the stickers.
export const whiteCrossOnlyState = (): CubeState => milestones().whiteCrossOnly
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/features/coach/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { CoachErrorBoundary, LessonBrowser, LessonPlayer } from './components'
export { warmMilestones } from './data/illustrative'
export { LESSONS, getLesson } from './data/lessons'
export type { Lesson, LessonStep } from './data/types'
export { stepAlgorithmId } from './data/types'
Expand Down
30 changes: 24 additions & 6 deletions apps/web/src/pages/Coach.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
import { CoachErrorBoundary, LessonBrowser, LessonPlayer } from '~/features/coach'
import { useEffect } from 'react'

export const Coach = ({ lessonId }: { lessonId?: string }) => (
<CoachErrorBoundary resetKey={lessonId}>
{lessonId ? <LessonPlayer lessonId={lessonId} /> : <LessonBrowser />}
</CoachErrorBoundary>
)
import { CoachErrorBoundary, LessonBrowser, LessonPlayer, warmMilestones } from '~/features/coach'

export const Coach = ({ lessonId }: { lessonId?: string }) => {
// Prewarm the solver-derived milestone cache during idle time: the first milestone
// access runs solveCube (~420ms) on the main thread, so doing it here — while the
// learner reads the first screen — means the first demo opens against a warm cache
// instead of freezing on the click (#17). Falls back to a short timer where
// requestIdleCallback is unavailable; the cleanup cancels it on unmount.
useEffect(() => {
if (typeof window.requestIdleCallback === 'function') {
const handle = window.requestIdleCallback(() => warmMilestones())
return () => window.cancelIdleCallback(handle)
}
const timer = setTimeout(warmMilestones, 200)
return () => clearTimeout(timer)
}, [])

return (
<CoachErrorBoundary resetKey={lessonId}>
{lessonId ? <LessonPlayer lessonId={lessonId} /> : <LessonBrowser />}
</CoachErrorBoundary>
)
}