265 lines
9.5 KiB
TypeScript
265 lines
9.5 KiB
TypeScript
import { test, expect } from '../fixtures/base';
|
|
import type { Page } from '@playwright/test';
|
|
import { ClassroomPage } from '../pages/classroom.page';
|
|
import { createSettingsStorage } from '../fixtures/test-data/settings';
|
|
import type { QuizQuestion } from '../../lib/types/stage';
|
|
|
|
const SETTINGS_STORAGE = createSettingsStorage({ sidebarCollapsed: false });
|
|
|
|
/**
|
|
* #657 — Quiz content surface, full authoring → playback journey.
|
|
*
|
|
* Two tests:
|
|
* A. Authoring — in Pro mode, add a question of every type, edit fields, and
|
|
* delete, asserting the editor reflects each op.
|
|
* B. Round trip — seed a quiz, edit it in Pro mode, then leave Pro mode and
|
|
* TAKE the quiz as a learner (answer single / multiple / short-answer,
|
|
* submit, grade). Asserts the Pro-mode edit actually reaches playback and
|
|
* that the produced quiz is playable and gradable end to end.
|
|
*
|
|
* The quiz scene is seeded straight into IndexedDB (the editor + generation
|
|
* pipeline don't emit quiz scenes through the mock generator), mirroring how
|
|
* classroom-interaction / interactive-keepalive specs set up a stage.
|
|
*/
|
|
async function seedQuiz(page: Page, stageId: string, questions: QuizQuestion[]) {
|
|
await page.addInitScript((settings) => {
|
|
localStorage.setItem('maic:account:settings-storage', settings);
|
|
}, SETTINGS_STORAGE);
|
|
await page.goto('/', { waitUntil: 'networkidle' });
|
|
await page.evaluate(
|
|
({ id, qs }) => {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const request = indexedDB.open('maic-documents', 1);
|
|
request.onupgradeneeded = () => {
|
|
const db = request.result;
|
|
db.createObjectStore('stages', { keyPath: 'id' });
|
|
const scenes = db.createObjectStore('scenes', { keyPath: ['stageId', 'id'] });
|
|
scenes.createIndex('by-stage', 'stageId');
|
|
db.createObjectStore('outlines', { keyPath: 'stageId' });
|
|
};
|
|
request.onsuccess = (event) => {
|
|
const db = (event.target as IDBOpenDBRequest).result;
|
|
const tx = db.transaction(['stages', 'scenes', 'outlines'], 'readwrite');
|
|
const now = Date.now();
|
|
tx.objectStore('stages').put({
|
|
id,
|
|
name: 'Quiz deck',
|
|
description: '',
|
|
language: 'en-US',
|
|
style: 'professional',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
dslVersion: '0.1.0',
|
|
});
|
|
tx.objectStore('scenes').put({
|
|
id: 'scene-quiz',
|
|
stageId: id,
|
|
type: 'quiz',
|
|
title: 'Checkpoint',
|
|
order: 0,
|
|
content: { type: 'quiz', questions: qs },
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
tx.objectStore('outlines').put({
|
|
stageId: id,
|
|
outline: { outlines: [], createdAt: now, updatedAt: now },
|
|
});
|
|
tx.oncomplete = () => {
|
|
db.close();
|
|
resolve();
|
|
};
|
|
tx.onerror = () => reject(tx.error);
|
|
};
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
},
|
|
{ id: stageId, qs: questions },
|
|
);
|
|
}
|
|
|
|
test.describe('Quiz content surface (#657)', () => {
|
|
test('authoring: add every question type, edit, and delete', async ({ page }, testInfo) => {
|
|
const STAGE = 'e2e-quiz-authoring';
|
|
await seedQuiz(page, STAGE, [
|
|
{
|
|
id: 'seed-q1',
|
|
type: 'single',
|
|
question: 'Seeded question?',
|
|
options: [
|
|
{ label: 'Yes', value: 'A' },
|
|
{ label: 'No', value: 'B' },
|
|
],
|
|
answer: ['A'],
|
|
points: 1,
|
|
},
|
|
]);
|
|
|
|
const classroom = new ClassroomPage(page);
|
|
await classroom.goto(STAGE);
|
|
await classroom.waitForLoaded();
|
|
await page.getByRole('switch').click(); // enter Pro mode
|
|
|
|
const surface = page.getByTestId('quiz-surface');
|
|
await expect(surface).toBeVisible({ timeout: 10_000 });
|
|
const cards = page.getByTestId('quiz-question');
|
|
await expect(cards).toHaveCount(1);
|
|
|
|
// Add one of every type via the inline "Add question" action.
|
|
const addType = async (name: string) => {
|
|
await page.getByRole('button', { name: 'Add question' }).click();
|
|
await page.getByRole('button', { name, exact: true }).click();
|
|
};
|
|
await addType('Single choice');
|
|
await addType('Multiple choice');
|
|
await addType('Short answer');
|
|
await expect(cards).toHaveCount(4);
|
|
// Every type is present on the cards (data-question-type attribute).
|
|
await expect(page.locator('[data-question-type="multiple"]')).toHaveCount(1);
|
|
await expect(page.locator('[data-question-type="short_answer"]')).toHaveCount(1);
|
|
|
|
// The just-added blank questions surface authoring validation hints.
|
|
await expect(page.getByText(/add question text/i).first()).toBeVisible();
|
|
|
|
// Edit the seeded question's text; the header summary reflects it live.
|
|
await cards
|
|
.first()
|
|
.getByRole('button', { name: /Seeded question/ })
|
|
.click();
|
|
await cards.first().locator('textarea').first().fill('Edited in the authoring test');
|
|
await expect(cards.first()).toContainText('Edited in the authoring test');
|
|
|
|
await testInfo.attach('authoring-all-types', {
|
|
body: await page.screenshot(),
|
|
contentType: 'image/png',
|
|
});
|
|
|
|
// Delete the last (short-answer) question.
|
|
await cards.nth(3).locator('button:has(.lucide-trash-2)').click();
|
|
await expect(cards).toHaveCount(3);
|
|
});
|
|
|
|
test('round trip: edit in Pro mode, then take the quiz and grade it', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const STAGE = 'e2e-quiz-roundtrip';
|
|
await seedQuiz(page, STAGE, [
|
|
{
|
|
id: 'q-single',
|
|
type: 'single',
|
|
question: 'Capital of France?',
|
|
options: [
|
|
{ label: 'Paris', value: 'A' },
|
|
{ label: 'Lyon', value: 'B' },
|
|
],
|
|
answer: ['A'],
|
|
points: 1,
|
|
},
|
|
{
|
|
id: 'q-multi',
|
|
type: 'multiple',
|
|
question: 'Pick the even numbers',
|
|
options: [
|
|
{ label: 'Two', value: 'A' },
|
|
{ label: 'Three', value: 'B' },
|
|
{ label: 'Four', value: 'C' },
|
|
],
|
|
answer: ['A', 'C'],
|
|
points: 1,
|
|
},
|
|
{
|
|
id: 'q-short',
|
|
type: 'short_answer',
|
|
question: 'Greet the class',
|
|
commentPrompt: 'Expect a greeting.',
|
|
hasAnswer: false,
|
|
points: 1,
|
|
},
|
|
]);
|
|
|
|
// Deterministic AI grading for the short-answer question.
|
|
await page.route('**/api/quiz-grade', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ score: 1, comment: 'Well phrased.' }),
|
|
}),
|
|
);
|
|
|
|
const classroom = new ClassroomPage(page);
|
|
await classroom.goto(STAGE);
|
|
await classroom.waitForLoaded();
|
|
|
|
// Pro mode has two Pro toggles (the playback header's and the edit chrome's,
|
|
// morphed via layoutId) and only one is the live control at a time. Click
|
|
// switches until the editor surface reaches the desired state, polling with
|
|
// a web-first assertion after each click (no fixed sleeps) and bailing as
|
|
// soon as it flips so a no-op click on the morph ghost can't over-toggle.
|
|
const surface = page.getByTestId('quiz-surface');
|
|
const atTarget = async (on: boolean) => {
|
|
try {
|
|
await expect(surface)[on ? 'toBeVisible' : 'toBeHidden']({ timeout: 1500 });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
const setProMode = async (on: boolean) => {
|
|
if (await atTarget(on)) return;
|
|
const switches = page.getByRole('switch');
|
|
const n = await switches.count();
|
|
for (let i = 0; i < n; i++) {
|
|
await switches
|
|
.nth(i)
|
|
.click()
|
|
.catch(() => {});
|
|
if (await atTarget(on)) return;
|
|
}
|
|
await expect(surface)[on ? 'toBeVisible' : 'toBeHidden']();
|
|
};
|
|
|
|
// --- Author: edit the single-choice question text in Pro mode.
|
|
await setProMode(true);
|
|
const cards = page.getByTestId('quiz-question');
|
|
await expect(cards).toHaveCount(3, { timeout: 10_000 });
|
|
await cards
|
|
.first()
|
|
.getByRole('button', { name: /Capital of France/ })
|
|
.click();
|
|
await cards.first().locator('textarea').first().fill('Capital of France (edited)?');
|
|
await expect(cards.first()).toContainText('Capital of France (edited)?');
|
|
|
|
// --- Leave Pro mode and take the quiz as a learner.
|
|
await setProMode(false);
|
|
await expect(page.getByTestId('quiz-surface')).toBeHidden();
|
|
await page.getByRole('button', { name: 'Start Quiz' }).click();
|
|
|
|
// The edited question text reaches playback — proves the edit round-trips.
|
|
await expect(page.getByText('Capital of France (edited)?')).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Answer: single (Paris), multiple (Two + Four), short-answer (text).
|
|
await page.getByRole('button', { name: /Paris/ }).click();
|
|
await page.getByRole('button', { name: /Two/ }).click();
|
|
await page.getByRole('button', { name: /Four/ }).click();
|
|
await page.getByPlaceholder('Type your answer here...').fill('Hello everyone!');
|
|
|
|
await testInfo.attach('playback-answering', {
|
|
body: await page.screenshot(),
|
|
contentType: 'image/png',
|
|
});
|
|
|
|
const submit = page.getByRole('button', { name: 'Submit Answers' });
|
|
await expect(submit).toBeEnabled();
|
|
await submit.click();
|
|
|
|
// Grading completes → reviewing. All three correct → 3 / 3.
|
|
await expect(page.getByText('/ 3')).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.getByText(/3\s+correct/i)).toBeVisible();
|
|
|
|
await testInfo.attach('playback-results', {
|
|
body: await page.screenshot(),
|
|
contentType: 'image/png',
|
|
});
|
|
});
|
|
});
|