diff --git a/frontend/e2e/review-archive.test.mjs b/frontend/e2e/review-archive.test.mjs new file mode 100644 index 0000000..44282ab --- /dev/null +++ b/frontend/e2e/review-archive.test.mjs @@ -0,0 +1,85 @@ +/** + * Smoke test: a learner who walks back to a finished mission must be able + * to read, and copy, the artifact that mission produced. + * + * The case this exists for: you derived an address (or an npub) three + * missions ago and now you need it again. Without this you would have to + * redo the mission, which the backend refuses. + * + * node e2e/review-archive.test.mjs + */ +import { + enterTree, + launch, + makeReporter, + openApp, + seedParticipant, + sleep, +} from './_lib.mjs' +import { getPublicKey, nip19 } from 'nostr-tools' + +const report = makeReporter('review-archive') + +const SK = new Uint8Array(32).fill(7) +const NPUB = nip19.npubEncode(getPublicKey(SK)) + +// Nostr order: [13, 14, 15, ...]. Seed past 14 (the identity mission) so +// the learner is sitting on 15 with 14 behind them. +const creds = await seedParticipant([13, 14]) +const browser = await launch() +try { + const page = await openApp(browser, creds, { npub: NPUB }) + await enterTree(page, 'Nostr') + await sleep(600) + + // Walk back to the identity mission. + const prev = page.getByRole('button', { name: /Previous mission on this flight path/i }) + report.assert((await prev.count()) > 0, 'a Previous control is offered') + await prev.first().click({ force: true }) + await sleep(900) + + // The Do step is where the archive lives, so go to it. + const doTab = page.getByRole('tab', { name: /Do it/i }) + if (await doTab.count()) { + await doTab.first().click({ force: true }) + await sleep(700) + } + + const body = await page.locator('#main-content, main, body').first().innerText() + + report.assert( + /completed this mission/i.test(body), + 'the revisited mission is shown as already completed', + ) + report.assert( + body.includes(NPUB), + 'the npub produced by that mission is shown again', + ) + report.assert( + /Your Nostr public key/i.test(body), + 'the artifact is labelled, not dumped as a bare string', + ) + + const copy = page.getByRole('button', { name: /Copy your nostr public key/i }) + report.assert((await copy.count()) > 0, 'a Copy control is offered for the artifact') + + // The learner must be able to get back to where they were. + const forward = page.getByRole('button', { name: /Next:|Forward/i }) + report.assert( + (await forward.count()) > 0, + 'a way forward to the current mission is offered', + ) + if (await forward.count()) { + await forward.first().click({ force: true }) + await sleep(900) + const after = await page.locator('#main-content, main, body').first().innerText() + report.assert( + !after.includes(NPUB) || /Public vs private key/i.test(after), + 'moving forward leaves the archived mission behind', + ) + } +} finally { + await browser.close() +} + +process.exit(report.finish() ? 0 : 1) diff --git a/frontend/e2e/run.mjs b/frontend/e2e/run.mjs index 67bf9b9..7d64a0c 100644 --- a/frontend/e2e/run.mjs +++ b/frontend/e2e/run.mjs @@ -15,6 +15,7 @@ const suite = [ ['secret-reveal.test.mjs', 'seed'], ['secret-reveal.test.mjs', 'nostrid'], ['sign-event.test.mjs'], + ['review-archive.test.mjs'], ['publish-confirm.test.mjs'], ['badge-share.test.mjs'], ['passphrase-fork.test.mjs'], diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 922d19c..e655d9e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -177,6 +177,14 @@ export interface CompleteMissionResponse { next_mission: number | null } +/** One row of the proof archive: what was submitted, and when. */ +export interface CompletionRecord { + mission: number + proof: string + /** Unix seconds. */ + completed_at: number +} + export interface RuntimeInfo { /** True when Lightning is talking to a real LN node (LNbits). */ lightning_real: boolean @@ -350,6 +358,16 @@ export const api = { getMyBadges: () => request('/participants/me/badges', { auth: 'participant' }), + /** + * Proof archive: what the learner submitted for each mission they've + * finished. Lets a revisited mission show the artifact it produced + * (an address, an npub, a txid) so they can copy it again later. + */ + getMyCompletions: () => + request('/participants/me/completions', { + auth: 'participant', + }), + completeMission: (mission: number, proof: string) => request('/missions/complete', { method: 'POST', diff --git a/frontend/src/views/LearnerView.tsx b/frontend/src/views/LearnerView.tsx index 02a746f..9c4bee1 100644 --- a/frontend/src/views/LearnerView.tsx +++ b/frontend/src/views/LearnerView.tsx @@ -17,6 +17,7 @@ import { TREES, missionById, type Badge, + type DoKind, type MissionDef, type Tree, type TreeMeta, @@ -122,6 +123,10 @@ export default function LearnerView({ participantId }: { participantId: string } const [completedMissions, setCompletedMissions] = useState([]) const [badges, setBadges] = useState([]) + // Proof archive, keyed by mission. Lets a revisited mission show the + // artifact it produced so the learner can copy an address or an npub + // again without hunting for it. + const [proofs, setProofs] = useState>({}) // Tree key of the most recently unlocked badge that we haven't yet // shown a celebration for. `null` once dismissed. Persists across // the (missionIdx, phase) state churn that resets per-mission UI. @@ -184,10 +189,15 @@ export default function LearnerView({ participantId }: { participantId: string } let cancelled = false ;(async () => { try { - const [p, b] = await Promise.all([api.getParticipant(), api.getMyBadges()]) + const [p, b, done] = await Promise.all([ + api.getParticipant(), + api.getMyBadges(), + api.getMyCompletions().catch(() => []), + ]) if (cancelled) return setCompletedMissions(p.completed_missions ?? []) setBadges(b) + setProofs(Object.fromEntries(done.map((c) => [c.mission, c.proof]))) setParticipantName(p.name ?? '') if (p.current_per_tree) { setCurrentPerTree(mergePerTreeMap(p.current_per_tree)) @@ -777,6 +787,10 @@ export default function LearnerView({ participantId }: { participantId: string } // Only credit the mission after the action succeeded. await api.completeMission(mission.id, proof) setDoOutcome(outcome) + // Keep the local archive in step, so navigating back to this + // mission later in the same session shows what was submitted + // without waiting for a reload. + setProofs((prev) => ({ ...prev, [mission.id]: proof })) // Mark complete *now*, not when the user clicks Next. Without // this, navigating away with Previous and returning with Next // would re-show the action button and let the user re-fire @@ -897,6 +911,7 @@ export default function LearnerView({ participantId }: { participantId: string } phase={phase} onChange={(p) => setPhase(p)} quizPassed={quizResult === 'correct'} + unlocked={isReviewing} />
{ + try { + await navigator.clipboard.writeText(proof) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // Clipboard can be blocked (insecure context, iframe perms). + // The value stays selectable in the block below. + } + } + + return ( +
+
+ {label} + +
+
+                {formatArchivedProof(kind, proof)}
+            
+
+ ) +} + /** * Nine tree-badge medallions, one per flight path. Filled = earned (the * learner finished every mission in the tree); outlined = in progress, @@ -1622,10 +1779,19 @@ function PhaseTabs({ phase, onChange, quizPassed, + unlocked = false, }: { phase: Phase onChange: (p: Phase) => void quizPassed: boolean + /** + * Free navigation between all three steps. Set on missions the learner + * has already completed: the gating exists to stop someone skipping + * ahead to the Do step, and there is nothing left to skip. Without it, + * coming back for an address you derived means re-reading the lesson + * and re-passing the quiz to reach the step that holds it. + */ + unlocked?: boolean }) { const order: Phase[] = ['learn', 'quiz', 'do'] const labels: Record = { learn: 'Read', quiz: 'Quiz', do: 'Do it' } @@ -1646,7 +1812,7 @@ function PhaseTabs({ const isPast = (p === 'learn' && (phase === 'quiz' || phase === 'do')) || (p === 'quiz' && phase === 'do' && quizPassed) - const clickable = isActive || isPast + const clickable = isActive || isPast || unlocked return (
+ {archivedProof && ( + + )}