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
30 changes: 30 additions & 0 deletions backend/src/modules/learning/services/progressService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,36 @@ describe('ProgressService', () => {
expect(progress.steps['step-a']).toEqual({ passed: false, attempts: 0, revealedHints: 3 });
});

it('stamps startedAt when the play-through begins and never moves it', () => {
ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', false, '2026-07-16T10:00:00.000Z', file);
const startedAt = ProgressService.getProgress('project-1', 'roadmap-1', file).startedAt;
expect(typeof startedAt).toBe('string');

ProgressService.recordValidation('project-1', 'roadmap-1', 'step-b', true, '2026-07-16T11:00:00.000Z', file);
ProgressService.recordRevealedHints('project-1', 'roadmap-1', 'step-a', 1, file);

expect(ProgressService.getProgress('project-1', 'roadmap-1', file).startedAt).toBe(startedAt);
});

it('leaves startedAt absent on entries written before the field existed', () => {
fs.writeFileSync(
file,
JSON.stringify({
version: 1,
entries: [
{
projectId: 'project-1',
roadmapId: 'roadmap-1',
updatedAt: '2026-07-16T10:00:00.000Z',
steps: { 'step-a': { passed: true, attempts: 1, revealedHints: 0 } },
},
],
})
);

expect(ProgressService.getProgress('project-1', 'roadmap-1', file).startedAt).toBeUndefined();
});

it('keys steps by stable id, independent of any ordering', () => {
ProgressService.recordValidation('project-1', 'roadmap-1', 'step-c', true, '2026-07-16T10:00:00.000Z', file);
ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', true, '2026-07-16T10:01:00.000Z', file);
Expand Down
9 changes: 8 additions & 1 deletion backend/src/modules/learning/services/progressService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface StepProgress {
export interface ProgressEntry {
projectId: string;
roadmapId: string;
/** ISO timestamp of the entry's creation — when the play-through began. Absent on entries written before it existed. */
startedAt?: string;
updatedAt: string;
steps: Record<string, StepProgress>;
}
Expand Down Expand Up @@ -45,6 +47,8 @@ export interface ProgressEntrySummary {
export interface RoadmapProgressResponse {
projectId: string;
roadmapId: string;
/** When this play-through began — absent when it hasn't started, or predates the field. */
startedAt?: string;
steps: Record<string, StepProgress>;
/** Present (true) once after an unreadable store was moved aside — the UI should tell the user. */
storeRecovered?: boolean;
Expand Down Expand Up @@ -85,6 +89,9 @@ export class ProgressService {
const store = this.readStore(filePath);
const entry = store.entries.find(e => e.projectId === projectId && e.roadmapId === roadmapId);
const response: RoadmapProgressResponse = { projectId, roadmapId, steps: entry?.steps ?? {} };
if (entry?.startedAt) {
response.startedAt = entry.startedAt;
}
if (this.storeRecovered) {
response.storeRecovered = true;
this.storeRecovered = false;
Expand Down Expand Up @@ -165,7 +172,7 @@ export class ProgressService {
): StepProgress {
let entry = store.entries.find(e => e.projectId === projectId && e.roadmapId === roadmapId);
if (!entry) {
entry = { projectId, roadmapId, updatedAt: '', steps: {} };
entry = { projectId, roadmapId, startedAt: new Date().toISOString(), updatedAt: '', steps: {} };
store.entries.push(entry);
}
entry.updatedAt = new Date().toISOString();
Expand Down
5 changes: 3 additions & 2 deletions docs/learning-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ Roadmap progression is persisted locally in `~/.torollo/progress.json`, next to
{
"projectId": "project-1751883322290",
"roadmapId": "resilient-three-tier",
"startedAt": "2026-07-16T19:42:03.000Z",
"updatedAt": "2026-07-16T20:11:00.000Z",
"steps": {
"first-server": {
Expand All @@ -113,15 +114,15 @@ Roadmap progression is persisted locally in `~/.torollo/progress.json`, next to
}
```

`passed` is the verdict of the **latest** validation (same semantics as the player's in-session display); `attempts` counts the validation runs that reached evaluation; `revealedHints` is the absolute number of revealed rungs on the step's hint ladder `[...hints, solution?]`. Validator results are deliberately **not** persisted — they describe a past container state; only the verdict survives. The top-level `version` is the migration contract: a reader that finds an unknown version (or an unparseable file) must not guess — the server moves the file aside as `progress.json.corrupt`, starts fresh, and reports it once via `storeRecovered` on the next progress read so the UI can tell the user. Writes are write-then-rename, so a crash mid-write cannot truncate the store. Deleting a project deletes its progress entries.
`passed` is the verdict of the **latest** validation (same semantics as the player's in-session display); `attempts` counts the validation runs that reached evaluation; `revealedHints` is the absolute number of revealed rungs on the step's hint ladder `[...hints, solution?]`; `startedAt` is stamped when the entry is created — i.e. on the play-through's first recorded activity — and never moves (absent on entries written before the field existed). Validator results are deliberately **not** persisted — they describe a past container state; only the verdict survives. The top-level `version` is the migration contract: a reader that finds an unknown version (or an unparseable file) must not guess — the server moves the file aside as `progress.json.corrupt`, starts fresh, and reports it once via `storeRecovered` on the next progress read so the UI can tell the user. Writes are write-then-rename, so a crash mid-write cannot truncate the store. Deleting a project deletes its progress entries.

### `GET /api/learning/progress`

Returns `{ "entries": [ { "projectId", "roadmapId", "updatedAt", "completedSteps" } ] }` — one summary per `(projectId, roadmapId)` play-through in the store, where `completedSteps` counts the steps whose latest validation passed. Used by surfaces that show progress across projects (e.g. the landing page's roadmap cards, which keep the most recent entry per roadmap). This endpoint never emits `storeRecovered` — that one-shot notice is reserved for the per-pair read below.

### `GET /api/learning/progress/:projectId/:roadmapId`

Returns `{ projectId, roadmapId, steps }` — `steps` is the per-step record above, `{}` when nothing was ever recorded. `storeRecovered: true` is present once after a corrupt/unknown-version store was discarded. The player calls this when opening a roadmap and resumes on the first step whose `passed` is not true.
Returns `{ projectId, roadmapId, steps }` — `steps` is the per-step record above, `{}` when nothing was ever recorded. `startedAt` is present when the entry exists and carries the field. `storeRecovered: true` is present once after a corrupt/unknown-version store was discarded. The player calls this when opening a roadmap and resumes on the first step whose `passed` is not true.

### `PUT /api/learning/progress/:projectId/:roadmapId/hints`

Expand Down
12 changes: 11 additions & 1 deletion frontend/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useState } from 'react';
import CanvasPage from '../pages/CanvasPage/CanvasPage';
import TerminalModal from '../features/terminal/components/TerminalModal';
import ProjectsPage from '../pages/ProjectsPage/ProjectsPage';
import type { LearningIntent, ProjectInfo, TerminalInfo } from '../shared/types';
import type { LearningExit, LearningIntent, ProjectInfo, TerminalInfo } from '../shared/types';

function App() {
const [activeProject, setActiveProject] = useState<ProjectInfo | null>(() => {
Expand All @@ -17,6 +17,9 @@ function App() {
// Session-only, deliberately not persisted: a reload must never replay
// an "open the learning panel" intent from a past navigation.
const [learningIntent, setLearningIntent] = useState<LearningIntent | null>(null);
// Reverse direction, same rule: where the home shell should land when the
// completion screen sends the learner out of the canvas.
const [learningExit, setLearningExit] = useState<LearningExit | null>(null);

const handleSelectProject = (project: ProjectInfo | null) => {
setActiveProject(project);
Expand All @@ -31,6 +34,8 @@ function App() {
<div style={{ height: '100vh', width: '100vw', display: 'flex', flexDirection: 'column' }}>
{!activeProject ? (
<ProjectsPage
initialLearning={learningExit}
onInitialLearningConsumed={() => setLearningExit(null)}
onSelectProject={(id, name, intent) => {
setLearningIntent(intent ?? null);
handleSelectProject({ id, name });
Expand All @@ -46,6 +51,11 @@ function App() {
handleSelectProject(null);
setActiveTerminal(null);
}}
onExitToLearning={target => {
setLearningExit(target);
handleSelectProject(null);
setActiveTerminal(null);
}}
onTerminalOpen={(id, name) => setActiveTerminal({ id, name })}
/>
)}
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/features/learning/components/LearningPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ import { GraduationCap, X } from 'lucide-react';
import { useLearningPlayer } from '../hooks/useLearningPlayer';
import RoadmapCatalog from './RoadmapCatalog';
import RoadmapPlayer from './RoadmapPlayer';
import type { ContainerData } from '../../../shared/types';
import type { ContainerData, LearningExit } from '../../../shared/types';
import type { NetworkConfig } from '../../../shared/types/network';

interface LearningPanelProps {
projectId: string;
/** Name of the project — shown on the completion receipt. */
projectName?: string;
/** Deep-link from the landing page: open directly on this roadmap. One-shot, consumed on mount. */
initialRoadmap?: { id: string; language: string } | null;
onClose: () => void;
/** Leave the canvas for the home shell (completion screen's navigation). */
onExit?: (target: LearningExit) => void;
containers?: ContainerData[];
networkConfig?: NetworkConfig;
}
Expand All @@ -23,8 +27,10 @@ interface LearningPanelProps {
*/
export default function LearningPanel({
projectId,
projectName,
initialRoadmap,
onClose,
onExit,
containers = [],
networkConfig = {
vpcConfig: { name: '', cidr: '' },
Expand Down Expand Up @@ -65,6 +71,8 @@ export default function LearningPanel({
player={player}
containers={containers}
networkConfig={networkConfig}
projectName={projectName}
onExit={onExit}
/>
) : player.roadmapLoading ? (
<div style={styles.loading}>{t('learning.player.loading')}</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import '../../../i18n';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import RoadmapCompletionScreen from './RoadmapCompletionScreen';
import type { Roadmap, RoadmapSummary } from '../../../shared/types/roadmap';

function jsonResponse(ok: boolean, body: unknown): Response {
return { ok, json: () => Promise.resolve(body) } as Response;
}

const roadmap: Roadmap = {
schemaVersion: 1,
id: 'example-first-architecture',
title: 'Your first architecture',
description: 'Build a minimal two-tier architecture.',
language: 'en',
steps: [
{
id: 'create-web-server',
title: 'Create the web server',
instruction: 'Drag an Ubuntu node named `web` onto the canvas.',
validators: [{ type: 'container_running', params: { node: 'web' } }],
},
{
id: 'add-database',
title: 'Add the database',
instruction: 'Add a Postgres node named `db`.',
validators: [{ type: 'table_exists', params: { node: 'db', table: 'invoices' } }],
},
],
};

const currentSummary: RoadmapSummary = {
id: roadmap.id,
title: roadmap.title,
description: roadmap.description,
language: 'en',
stepCount: 2,
};

const otherSummary: RoadmapSummary = {
id: 'resilient-three-tier',
title: 'Deploy a resilient three-tier app',
description: 'LB, app fleet, database.',
language: 'en',
stepCount: 10,
};

describe('RoadmapCompletionScreen', () => {
let fetchMock: ReturnType<typeof vi.fn>;
let writeText: ReturnType<typeof vi.fn>;

function mockApi({
summaries = [currentSummary, otherSummary],
entries = [] as unknown[],
} = {}) {
fetchMock.mockImplementation((url: string) => {
if (url.includes('/api/learning/progress')) {
return Promise.resolve(jsonResponse(true, { entries }));
}
if (url.includes('/api/learning/roadmaps')) {
return Promise.resolve(jsonResponse(true, summaries));
}
return Promise.resolve(jsonResponse(true, {}));
});
}

function renderScreen({ onDismiss = vi.fn(), onExit = vi.fn() } = {}) {
render(
<RoadmapCompletionScreen
roadmap={roadmap}
projectName="My first lab"
runTimes={{ startedAt: '2026-07-15T09:00:00.000Z', finishedAt: '2026-07-15T09:45:00.000Z' }}
onDismiss={onDismiss}
onExit={onExit}
/>
);
return { onDismiss, onExit };
}

beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
});
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('shows the celebration: title, run receipt, steps recap and derived skills', async () => {
mockApi();
renderScreen();

expect(screen.getByText('Your first architecture')).toBeInTheDocument();
expect(
screen.getByText('Every step passed against your running containers.')
).toBeInTheDocument();

// The receipt states the real run, not a paraphrase.
expect(screen.getByText('roadmap: example-first-architecture')).toBeInTheDocument();
expect(screen.getByText('project: My first lab')).toBeInTheDocument();
expect(screen.getByText('steps: 2/2 passed')).toBeInTheDocument();
expect(screen.getByText(/^started: /)).toBeInTheDocument();
expect(screen.getByText(/^finished: /)).toBeInTheDocument();

// Every step, with its position — the recap is the roadmap's own outline.
expect(screen.getByText('Create the web server')).toBeInTheDocument();
expect(screen.getByText('Add the database')).toBeInTheDocument();

// Skills come off the validators (container_running → Containers, table_exists → SQL).
expect(screen.getByText('Containers')).toBeInTheDocument();
expect(screen.getByText('SQL')).toBeInTheDocument();

expect(await screen.findByText('Next roadmap')).toBeInTheDocument();

// The share card: same facts as the receipt/recap, rendered onto a canvas.
expect(screen.getByText('Share image')).toBeInTheDocument();
expect(
screen.getByRole('img', { name: 'Shareable image card for “Your first architecture”' })
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Download image' })).toBeInTheDocument();
});

it('copies a share post as plain text when the browser cannot copy images (jsdom default)', async () => {
mockApi();
renderScreen();

fireEvent.click(screen.getByText('Copy share post'));

expect(writeText).toHaveBeenCalledWith(
'I just completed “Your first architecture” on Torollo — 2/2 steps verified against real Docker containers running on my machine. Free and open source: torollo.app'
);
expect(await screen.findByText('Copied — paste it anywhere')).toBeInTheDocument();
});

it('copies the share image and the text as one clipboard item when supported', async () => {
mockApi();
const write = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { write, writeText },
configurable: true,
});
class FakeClipboardItem {
items: Record<string, unknown>;
constructor(items: Record<string, unknown>) {
this.items = items;
}
}
vi.stubGlobal('ClipboardItem', FakeClipboardItem);
const originalToBlob = HTMLCanvasElement.prototype.toBlob;
HTMLCanvasElement.prototype.toBlob = vi.fn(function toBlob(callback: BlobCallback) {
callback(new Blob(['png'], { type: 'image/png' }));
});

try {
renderScreen();
fireEvent.click(screen.getByText('Copy share post'));

expect(await screen.findByText('Copied — paste it anywhere')).toBeInTheDocument();
expect(write).toHaveBeenCalledTimes(1);
const item = write.mock.calls[0][0][0] as FakeClipboardItem;
expect(Object.keys(item.items)).toEqual(['image/png', 'text/plain']);
expect(writeText).not.toHaveBeenCalled();
} finally {
HTMLCanvasElement.prototype.toBlob = originalToBlob;
}
});

it('routes "Next roadmap" to the first unstarted roadmap of the catalogue', async () => {
mockApi();
const { onExit } = renderScreen();

fireEvent.click(await screen.findByText('Next roadmap'));

expect(onExit).toHaveBeenCalledWith({ kind: 'roadmap', summary: otherSummary });
});

it('skips roadmaps already finished and prefers an unstarted one', async () => {
const third: RoadmapSummary = { ...otherSummary, id: 'redis-queue-workers', title: 'Queue it' };
mockApi({
summaries: [currentSummary, otherSummary, third],
entries: [
// otherSummary is finished (10/10) — the next win is the untouched one.
{ projectId: 'p1', roadmapId: otherSummary.id, updatedAt: '2026-07-15T09:00:00.000Z', completedSteps: 10 },
],
});
const { onExit } = renderScreen();

fireEvent.click(await screen.findByText('Next roadmap'));

expect(onExit).toHaveBeenCalledWith({ kind: 'roadmap', summary: third });
});

it('falls back to the catalogue with a note when every roadmap is done', async () => {
mockApi({ summaries: [currentSummary] });
const { onExit } = renderScreen();

expect(
await screen.findByText(/That's every roadmap in the catalogue for now/)
).toBeInTheDocument();

fireEvent.click(screen.getByText('All roadmaps'));
expect(onExit).toHaveBeenCalledWith({ kind: 'catalog' });
});

it('returns to the canvas through the keep-building link', async () => {
mockApi();
const { onDismiss } = renderScreen();

fireEvent.click(screen.getByText('Keep building'));
expect(onDismiss).toHaveBeenCalledOnce();
});
});
Loading
Loading