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
28 changes: 21 additions & 7 deletions hugo-apps/src/app-space/AppSpace.vue
Original file line number Diff line number Diff line change
Expand Up @@ -341,8 +341,19 @@ function trackTotalTime(track: AppSpaceTrack): number {
// satisfiable. A segment carrying a synthetic lane prize (CHECKPOINT+prizeId)
// completes the track's reward as soon as THAT lane's tutorials are done; a
// downstream CHECKPOINT (no prizeId) requires all preceding work. We model this
// as: the track is complete when every REQUIRED checkpoint/prize in it is
// unlocked, OR (no checkpoints authored) when all tutorials are done.
// as: the track is complete when a reward is claimable, OR (no checkpoints
// authored) when all tutorials are done.
//
// #2889: prize-aware restriction of the old `checkpoints.some(unlocked)` rule.
// Two checkpoint kinds, by authoring intent:
// • lane-prize (prizeId set) — authored to earn the reward the moment ITS lane
// finishes, so ANY unlocked lane-prize completes the track (keeps `some`).
// • plain downstream (no prizeId) — a join that requires preceding work; only
// the TERMINAL one marks the whole track done. Keying off *any* unlocked
// plain checkpoint would falsely complete a track whose authored checkpoint
// sits at a non-terminal order while later tutorials remain. `allSegmentItems`
// flattens segments in authored (ascending `order`) order, so the LAST
// non-prize checkpoint is the highest-order one.
function isTrackComplete(track: AppSpaceTrack): boolean {
if (!hasSegments(track)) {
const tuts = tutorialItems(track)
Expand All @@ -351,11 +362,14 @@ function isTrackComplete(track: AppSpaceTrack): boolean {
const items = allSegmentItems(track)
const checkpoints = items.filter(i => i.type === 'CHECKPOINT')
if (checkpoints.length > 0) {
// Any earnable checkpoint (lane prize OR downstream) being unlocked means a
// reward is claimable → the track's goal is met. A lane-prize checkpoint
// unlocks per-lane (the authored "this branch alone earns it" case);
// a downstream checkpoint unlocks only when all lanes finish.
return checkpoints.some(c => isSegmentItemUnlocked(c))
// Any lane-prize checkpoint being unlocked earns the reward on its own.
const lanePrizeUnlocked = checkpoints.some(c => c.prizeId != null && isSegmentItemUnlocked(c))
// Otherwise, only the highest-order plain (non-prize) checkpoint counts —
// the terminal join. Lower-order plain checkpoints never complete the track.
const plainCheckpoints = checkpoints.filter(c => c.prizeId == null)
const terminalPlain = plainCheckpoints[plainCheckpoints.length - 1]
const terminalPlainUnlocked = terminalPlain != null && isSegmentItemUnlocked(terminalPlain)
return lanePrizeUnlocked || terminalPlainUnlocked
}
const tuts = segmentTutorials(track)
return tuts.length > 0 && tuts.every(itemDone)
Expand Down
162 changes: 162 additions & 0 deletions hugo-apps/src/app-space/__tests__/AppSpace.segments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,109 @@ const clusterPayload = () => ({
}],
})

// #2889: a track with a NON-TERMINAL plain checkpoint (no prizeId) at a lower
// order than later tutorials — the latent shape the highest-order rule guards.
// `earlyUnlocked` toggles whether the mid-track checkpoint is unlocked while the
// downstream tutorial (and terminal checkpoint) remain incomplete. Only the
// TERMINAL checkpoint (order 40) should be able to complete the track.
const nonTerminalCheckpointPayload = (earlyUnlocked: boolean, terminalUnlocked: boolean) => ({
eventId: 10000008,
eventName: 'TechEd 2026',
eventType: 'TECHED',
authenticated: true,
type: 'COMPLEX',
paths: [{
id: 3,
title: 'Two-stage track with a mid checkpoint',
description: 'Checkpoint before more work',
items: [],
segments: [
{
order: 10, kind: 'linear', items: [
tutorial({ imsId: 20, title: 'Stage one tutorial', status: 'COMPLETED', unlocked: true }),
],
},
{
// Non-terminal plain checkpoint: authored mid-track, no prizeId.
order: 20, kind: 'linear', items: [
{
imsId: 21, title: 'Mid checkpoint', type: 'CHECKPOINT', status: '', progress: 0,
experience: '', timeToComplete: 0, url: '', description: '',
recordId: 0, temporarilyUnavailable: false, unlocked: earlyUnlocked,
},
],
},
{
order: 30, kind: 'linear', items: [
tutorial({ imsId: 22, title: 'Stage two tutorial', status: terminalUnlocked ? 'COMPLETED' : '', unlocked: earlyUnlocked }),
],
},
{
// Terminal plain checkpoint (highest order): the real completion gate.
order: 40, kind: 'linear', items: [
{
imsId: 23, title: 'Final checkpoint', type: 'CHECKPOINT', status: '', progress: 0,
experience: '', timeToComplete: 0, url: '', description: '',
recordId: 0, temporarilyUnavailable: false, unlocked: terminalUnlocked,
},
],
},
],
}],
})

// #2889: a fork track where the lane-prize checkpoint (prizeId) is unlocked
// because its lane finished, but the DOWNSTREAM join checkpoint is still locked.
// Discriminates the prize-aware rule: the unlocked lane prize alone completes
// the track even though the terminal (highest-order) plain checkpoint is locked.
const lanePrizeOnlyPayload = () => ({
eventId: 10000009,
eventName: 'TechEd 2026',
eventType: 'TECHED',
authenticated: true,
type: 'COMPLEX',
paths: [{
id: 4,
title: 'Lane prize earns the reward',
description: 'One branch suffices',
items: [],
segments: [
{
order: 10, kind: 'fork', lanes: [
{
label: 'MCP',
items: [
tutorial({ imsId: 30, title: 'Finish the MCP lane', status: 'COMPLETED', unlocked: true }),
{
imsId: null, title: 'MCP prize', type: 'CHECKPOINT', status: '', progress: 0,
experience: '', timeToComplete: 0, url: '', description: '',
recordId: 0, temporarilyUnavailable: false, prizeId: 700,
unlocked: true, // this lane is done → lane prize unlocked
},
],
},
{
label: 'Agents',
items: [
tutorial({ imsId: 31, title: 'Agents lane still going', status: '', unlocked: true }),
],
},
],
},
{
// Downstream join: needs ALL lanes → still locked while Agents is open.
order: 20, kind: 'linear', items: [
{
imsId: 32, title: 'Grand finale checkpoint', type: 'CHECKPOINT', status: '', progress: 0,
experience: '', timeToComplete: 0, url: '', description: '',
recordId: 0, temporarilyUnavailable: false, unlocked: false,
},
],
},
],
}],
})

async function openTrack(wrapper: ReturnType<typeof mount>) {
const card = wrapper.find('.track-card')
expect(card.exists()).toBe(true)
Expand Down Expand Up @@ -209,3 +312,62 @@ describe('AppSpace segment rendering', () => {
expect(locked.length).toBe(0)
})
})

// #2889: isTrackComplete / progressPercent over segment payloads. These
// aggregates drive the overview grid (completion badge + progress ring); the
// functions aren't exported, so we assert through the rendered card.
describe('AppSpace track completion aggregate (#2889)', () => {
beforeEach(() => {
vi.restoreAllMocks()
})

function mountFor(eventId: number, body: unknown) {
stubFetch((url) => {
if (url.includes('getAppSpaceProgress')) return { ok: true, status: 200, body }
return { ok: false, status: 404, body: {} }
})
window.history.replaceState({}, '', `/app-space/?eventId=${eventId}`)
return mount(AppSpace)
}

it('does NOT complete the track when only a non-terminal plain checkpoint is unlocked', async () => {
// Mid checkpoint (order 20) unlocked, but downstream tutorial + terminal
// checkpoint (order 40) are not → track must read incomplete. This is the
// exact latent bug the old `checkpoints.some(unlocked)` rule hit.
const wrapper = mountFor(10000008, nonTerminalCheckpointPayload(true, false))
await flushPromises()
expect(wrapper.find('.track-card--complete').exists()).toBe(false)
expect(wrapper.find('.track-card__badge').exists()).toBe(false)
// 1 of 2 tutorials done → 50%.
expect(wrapper.find('.progress-ring-text').text()).toBe('50%')
})

it('completes the track when the terminal (highest-order) checkpoint unlocks', async () => {
const wrapper = mountFor(10000008, nonTerminalCheckpointPayload(true, true))
await flushPromises()
expect(wrapper.find('.track-card--complete').exists()).toBe(true)
expect(wrapper.find('.track-card__badge').exists()).toBe(true)
expect(wrapper.find('.progress-ring-text').text()).toBe('100%')
})

it('completes the track when a lane-prize checkpoint unlocks, even with a locked downstream checkpoint', async () => {
// Lane prize (prizeId) unlocked because its lane finished; the downstream
// join checkpoint is still LOCKED. Prize-aware rule → complete. Reverting to
// the old `checkpoints.some(unlocked)` would also pass here, but reverting to
// a naive highest-order-only rule would (wrongly) fail — this guards both.
const wrapper = mountFor(10000009, lanePrizeOnlyPayload())
await flushPromises()
expect(wrapper.find('.track-card--complete').exists()).toBe(true)
expect(wrapper.find('.track-card__badge').exists()).toBe(true)
// MCP lane tutorial done, Agents lane not → 1 of 2 = 50%.
expect(wrapper.find('.progress-ring-text').text()).toBe('50%')
})

it('does NOT complete the fork track while every checkpoint is locked', async () => {
const wrapper = mountFor(10000006, forkPayload(true, false))
await flushPromises()
expect(wrapper.find('.track-card--complete').exists()).toBe(false)
// Setup tutorial done, both lane tutorials incomplete → 1 of 3 = 33%.
expect(wrapper.find('.progress-ring-text').text()).toBe('33%')
})
})
Loading