diff --git a/src/express/results.test.ts b/src/express/results.test.ts index 998b545..03edc07 100644 --- a/src/express/results.test.ts +++ b/src/express/results.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { EngineError } from '../api/client' import { - correctIndex, forgetReads, isStuckVideo, keepSettled, questionsOf, remembering, settleLimited, toMarkdown, videoStatus, withPlanItem, type Material, + correctIndex, decideMaterial, forgetReads, isStuckVideo, keepSettled, questionsOf, remembering, settleLimited, toMarkdown, videoStatus, withDecisions, withPlanItem, type Material, } from './results' import { sentencesOf, scriptLang } from '../components/AudioPlayer' import type { Schedule } from './schedules' @@ -160,3 +160,23 @@ describe('videoStatus', () => { expect(videoStatus({ status: 'cancelled', latest_error: 'clip_outcome_unknown' })).toBe('failed') }) }) + +describe('withDecisions', () => { + afterEach(() => vi.unstubAllGlobals()) + const scorm = (status: Material['status']) => ({ + id: 7, agentId: 3, kind: 'scorm', status, revision: 1, createdAt: '2026-10-29T00:00:00Z', sources: [], + parts: [{ id: 7, revision: 1, kind: 'summary', status }, { id: 8, revision: 1, kind: 'quiz', status }], + }) as unknown as Material + + it('shows a decision before any read says so, and drops it once one does', async () => { + vi.stubGlobal('fetch', async () => new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })) + await decideMaterial(scorm('ready'), 'accept') + // A remembered read from before the decision still says ready. + const shown = withDecisions(scorm('ready')) + expect(shown.status).toBe('accepted') + expect(shown.parts.map(p => p.status)).toEqual(['accepted', 'accepted']) + // The engine's own answer wins from then on, even if the material is later edited back to ready. + expect(withDecisions(scorm('accepted')).status).toBe('accepted') + expect(withDecisions(scorm('ready')).status).toBe('ready') + }) +}) diff --git a/src/express/results.ts b/src/express/results.ts index 28c5f6b..6b3a952 100644 --- a/src/express/results.ts +++ b/src/express/results.ts @@ -270,7 +270,7 @@ export async function listResults(): Promise<{ materials: Material[]; sources: F const materials = await settleLimited(content, PER_SCHEDULE_LIMIT, readMaterials) const sources = await settleLimited(research, PER_SCHEDULE_LIMIT, readSources) const newest = (a: T, b: T) => b.createdAt.localeCompare(a.createdAt) - return { materials: keepSettled(materials).sort(newest), sources: keepSettled(sources).sort(newest) } + return { materials: keepSettled(materials).map(withDecisions).sort(newest), sources: keepSettled(sources).sort(newest) } } const draftContent = (m: Material, id: number) => @@ -297,11 +297,29 @@ export const cancelVideo = (m: Material) => request(`${AGENTS}/${m.agentId}/vide export const retryMaterial = ({ agentId, retry }: Material) => retry && 'run' in retry ? runNow(agentId) : retryItem(agentId, retry!.planId, retry!.itemId) +// A decision the engine took, kept until a read of its part says so too: the read right after it +// may be the last poll's (a busy engine) and still say ready, which left Approve looking undone. +const decided = new Map() +const partKey = (m: Material, p: Part) => `${materialKey(m)}-${p.id}` + +export function withDecisions(m: Material): Material { + let changed = false + const parts = m.parts.map(p => { + const status = decided.get(partKey(m, p)) + if (!status) return p + if (p.status !== 'ready') { decided.delete(partKey(m, p)); return p } + changed = true + return { ...p, status } + }) + return changed ? { ...m, parts, status: combined(parts) } : m +} + // Every part waiting for review gets the same decision, so a SCORM module is approved as one. export async function decideMaterial(m: Material, decision: 'accept' | 'reject') { for (const p of m.parts.filter(p => p.status === 'ready')) { if (m.kind === 'video') await request(`${AGENTS}/${m.agentId}/video-drafts/${p.id}/review`, { method: 'POST', json: { decision } }) else await request(`${AGENTS}/${m.agentId}/material-drafts/${p.id}/decision`, { method: 'POST', json: { expected_revision: p.revision, decision } }) + decided.set(partKey(m, p), decision === 'accept' ? 'accepted' : 'rejected') } } diff --git a/src/pages/Results.tsx b/src/pages/Results.tsx index bc329ad..f4ccf74 100644 --- a/src/pages/Results.tsx +++ b/src/pages/Results.tsx @@ -8,7 +8,7 @@ import { Empty, PageHead, SkeletonCards, SkeletonLines, SkeletonRows } from '../ import { toast } from '../components/Toast' import { describeRule, kindLabel, type ContentKind } from '../express/parse' import { - canCancelVideo, cancelVideo, decideMaterial, download, exportPptx, fileName, isWorking, printPdf, listOf, listResults, materialContent, RESULTS_POLL, materialKey, questionsOf, retryMaterial, reviewSource, slidesOf, textOf, + canCancelVideo, cancelVideo, decideMaterial, download, exportPptx, fileName, isWorking, printPdf, listOf, listResults, materialContent, RESULTS_POLL, materialKey, questionsOf, retryMaterial, reviewSource, slidesOf, textOf, withDecisions, titleOf, toMarkdown, type FoundSource, type Material, type MaterialContent, type MaterialStatus, } from '../express/results' import { MASTERY, scormPackage } from '../express/scorm' @@ -194,6 +194,9 @@ export function Preview({ material: m, onClose }: { material: Material; onClose: mutationFn: (decision: 'accept' | 'reject') => decideMaterial(m, decision), onSuccess: (_, decision) => { toast(t(decision === 'accept' ? 'res.accepted' : 'res.rejected')) + // Shown at once; reading every schedule again takes a while, longer on a busy engine. + queryClient.setQueryData>>(['results'], old => + old && { ...old, materials: old.materials.map(withDecisions) }) queryClient.invalidateQueries({ queryKey: ['results'] }) }, })