diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 9e7f434..f47af98 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -44,6 +44,9 @@ jobs: exit 1 fi cp -r dist _site/ + # Slide walkthrough of the application, served at main/walkthrough/ + mkdir -p _site/walkthrough + cp -r docs/walkthrough/* _site/walkthrough/ - name: Checkout gh-pages branch uses: actions/checkout@v4 diff --git a/.github/workflows/pr-preview-comment.yml b/.github/workflows/pr-preview-comment.yml index 575b65a..8276ba0 100644 --- a/.github/workflows/pr-preview-comment.yml +++ b/.github/workflows/pr-preview-comment.yml @@ -29,6 +29,7 @@ jobs: **πŸ“‹ Live PR Preview Links:** - 🌐 [DITA WebHelp Preview](https://${owner}.github.io/${repo}/pr-${prNumber}/) - πŸ“š [Storybook Preview](https://${owner}.github.io/${repo}/pr-${prNumber}/storybook/) + - 🎞️ [Walkthrough slides](https://${owner}.github.io/${repo}/pr-${prNumber}/walkthrough/) **ℹ️ About PR Previews:** - Preview deploys automatically when you push commits to this PR @@ -40,6 +41,7 @@ jobs: - 🏠 [Quiz Index (Home)](https://${owner}.github.io/${repo}/main/quiz-index.html) - πŸ“ [Quiz Examples](https://${owner}.github.io/${repo}/main/quiz-examples.html) - πŸ“Š [Analysis Examples](https://${owner}.github.io/${repo}/main/analysis-examples.html) + - 🎞️ [Walkthrough slides](https://${owner}.github.io/${repo}/main/walkthrough/) **πŸ§ͺ Testing Locally:** \`\`\`bash diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 6f7a017..36cb994 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -54,6 +54,11 @@ jobs: echo "⚠️ Warning: dita-demo not found, skipping DITA deployment" fi + # Slide walkthrough of the application + mkdir -p _site/pr-${PR_NUMBER}/walkthrough + cp -r docs/walkthrough/* _site/pr-${PR_NUMBER}/walkthrough/ + echo "βœ… Walkthrough copied to _site/pr-${PR_NUMBER}/walkthrough/" + # Copy Storybook build to PR directory if [ -d "storybook-static" ]; then mkdir -p _site/pr-${PR_NUMBER}/storybook diff --git a/docs/MAINTAINERS.md b/docs/MAINTAINERS.md index bc3a11d..7474e97 100644 --- a/docs/MAINTAINERS.md +++ b/docs/MAINTAINERS.md @@ -60,6 +60,7 @@ The demo pages load `dist/sonar-quiz.iife.js` and provide the hidden config span | Published output | `npm run check:answer-leak -- dita/out/oxygen` | Manual, developer only: fails on plaintext answers before `update-dita-demo` | | E2E | `npm run test:e2e` and `npm run test:e2e:encrypted` | CI matrix | | Storybook build | `npm run build-storybook` | CI, on every push and PR | +| Walkthrough slides | `npm run build:dita:plain && npm run walkthrough:capture` | Manual: re-captures the screenshots in `docs/walkthrough/img/`. Pages and PR previews publish `docs/walkthrough/` at `/main/walkthrough/` and `/pr-/walkthrough/` | | Release | Tag `vX.Y.Z` on `main` | `release.yml` builds and publishes; see `docs/RELEASE.md`. Last release: `v0.2.0`, 2025-11-29. Bump `package.json` first β€” `VERSION` in the bundle is injected from it. | **Raising the ratchets.** The coverage thresholds and `--max-gaps` value are set to the measured diff --git a/docs/walkthrough/img/01-logged-out.png b/docs/walkthrough/img/01-logged-out.png new file mode 100644 index 0000000..26a8b98 Binary files /dev/null and b/docs/walkthrough/img/01-logged-out.png differ diff --git a/docs/walkthrough/img/02-validation.png b/docs/walkthrough/img/02-validation.png new file mode 100644 index 0000000..c596357 Binary files /dev/null and b/docs/walkthrough/img/02-validation.png differ diff --git a/docs/walkthrough/img/03-create.png b/docs/walkthrough/img/03-create.png new file mode 100644 index 0000000..db942ec Binary files /dev/null and b/docs/walkthrough/img/03-create.png differ diff --git a/docs/walkthrough/img/04-confirm.png b/docs/walkthrough/img/04-confirm.png new file mode 100644 index 0000000..db5cb77 Binary files /dev/null and b/docs/walkthrough/img/04-confirm.png differ diff --git a/docs/walkthrough/img/05-mcq.png b/docs/walkthrough/img/05-mcq.png new file mode 100644 index 0000000..53c6129 Binary files /dev/null and b/docs/walkthrough/img/05-mcq.png differ diff --git a/docs/walkthrough/img/06-numeric.png b/docs/walkthrough/img/06-numeric.png new file mode 100644 index 0000000..019e63b Binary files /dev/null and b/docs/walkthrough/img/06-numeric.png differ diff --git a/docs/walkthrough/img/07-analysis.png b/docs/walkthrough/img/07-analysis.png new file mode 100644 index 0000000..66f12a4 Binary files /dev/null and b/docs/walkthrough/img/07-analysis.png differ diff --git a/docs/walkthrough/img/08-badges.png b/docs/walkthrough/img/08-badges.png new file mode 100644 index 0000000..13ac27c Binary files /dev/null and b/docs/walkthrough/img/08-badges.png differ diff --git a/docs/walkthrough/img/09-help.png b/docs/walkthrough/img/09-help.png new file mode 100644 index 0000000..6d71030 Binary files /dev/null and b/docs/walkthrough/img/09-help.png differ diff --git a/docs/walkthrough/img/10-narrow.png b/docs/walkthrough/img/10-narrow.png new file mode 100644 index 0000000..93e05b9 Binary files /dev/null and b/docs/walkthrough/img/10-narrow.png differ diff --git a/docs/walkthrough/img/11-timeout.png b/docs/walkthrough/img/11-timeout.png new file mode 100644 index 0000000..4cfbef1 Binary files /dev/null and b/docs/walkthrough/img/11-timeout.png differ diff --git a/docs/walkthrough/img/12-lockout.png b/docs/walkthrough/img/12-lockout.png new file mode 100644 index 0000000..5afe2fd Binary files /dev/null and b/docs/walkthrough/img/12-lockout.png differ diff --git a/docs/walkthrough/img/13-instructor-login.png b/docs/walkthrough/img/13-instructor-login.png new file mode 100644 index 0000000..c4589a6 Binary files /dev/null and b/docs/walkthrough/img/13-instructor-login.png differ diff --git a/docs/walkthrough/img/14-instructor-quiz.png b/docs/walkthrough/img/14-instructor-quiz.png new file mode 100644 index 0000000..86ba79a Binary files /dev/null and b/docs/walkthrough/img/14-instructor-quiz.png differ diff --git a/docs/walkthrough/img/15-student-answers.png b/docs/walkthrough/img/15-student-answers.png new file mode 100644 index 0000000..034638b Binary files /dev/null and b/docs/walkthrough/img/15-student-answers.png differ diff --git a/docs/walkthrough/img/16-analysis-instructor.png b/docs/walkthrough/img/16-analysis-instructor.png new file mode 100644 index 0000000..43339b9 Binary files /dev/null and b/docs/walkthrough/img/16-analysis-instructor.png differ diff --git a/docs/walkthrough/img/17-scores.png b/docs/walkthrough/img/17-scores.png new file mode 100644 index 0000000..2bc5fa7 Binary files /dev/null and b/docs/walkthrough/img/17-scores.png differ diff --git a/docs/walkthrough/img/18-reset-pin.png b/docs/walkthrough/img/18-reset-pin.png new file mode 100644 index 0000000..28063cb Binary files /dev/null and b/docs/walkthrough/img/18-reset-pin.png differ diff --git a/docs/walkthrough/img/19-erase.png b/docs/walkthrough/img/19-erase.png new file mode 100644 index 0000000..59f86ca Binary files /dev/null and b/docs/walkthrough/img/19-erase.png differ diff --git a/docs/walkthrough/img/20-forged-flag.png b/docs/walkthrough/img/20-forged-flag.png new file mode 100644 index 0000000..26a8b98 Binary files /dev/null and b/docs/walkthrough/img/20-forged-flag.png differ diff --git a/docs/walkthrough/img/shots.js b/docs/walkthrough/img/shots.js new file mode 100644 index 0000000..ef47410 --- /dev/null +++ b/docs/walkthrough/img/shots.js @@ -0,0 +1,314 @@ +// Generated by scripts/capture-walkthrough.mjs. Pin positions in % of each image. +window.WALKTHROUGH_SHOTS = { + "01-logged-out": { + "panel": { + "x": 54, + "y": 10.13, + "w": 43.97, + "h": 14.31 + }, + "help": { + "x": 94.92, + "y": 11.25, + "w": 2.03, + "h": 3.56 + }, + "notice": { + "x": 0.86, + "y": 35.77, + "w": 85.78, + "h": 5.22 + }, + "table": { + "x": 0.86, + "y": 42.99, + "w": 31.13, + "h": 29.97 + } + }, + "02-validation": { + "hint": { + "x": 52.73, + "y": 19.44, + "w": 44.22, + "h": 3.81 + }, + "field": { + "x": 63.52, + "y": 15.07, + "w": 10.78, + "h": 4 + }, + "button": { + "x": 83.36, + "y": 15.07, + "w": 4.45, + "h": 3.75 + } + }, + "03-create": { + "button": { + "x": 85.16, + "y": 15.07, + "w": 4.92, + "h": 3.75 + } + }, + "04-confirm": { + "pin": { + "x": 74.3, + "y": 15.07, + "w": 9.06, + "h": 4 + }, + "hint": { + "x": 52.73, + "y": 19.44, + "w": 44.22, + "h": 3.81 + } + }, + "05-mcq": { + "progress": { + "x": 73.08, + "y": 14.5, + "w": 11.09, + "h": 3.19 + }, + "header": { + "x": 31.48, + "y": 31.7, + "w": 29.48, + "h": 5.6 + }, + "correct": { + "x": 32.06, + "y": 41.74, + "w": 4.87, + "h": 3.72 + }, + "incorrect": { + "x": 32.06, + "y": 50.84, + "w": 5.58, + "h": 3.72 + } + }, + "06-numeric": { + "error": { + "x": 43.94, + "y": 44.21, + "w": 19.71, + "h": 3.72 + }, + "correct": { + "x": 43.94, + "y": 80.09, + "w": 4.87, + "h": 3.72 + } + }, + "07-analysis": { + "label": { + "x": 0.9, + "y": 42.28, + "w": 12.99, + "h": 5.6 + }, + "cell": { + "x": 13.42, + "y": 42.28, + "w": 25.52, + "h": 5.6 + }, + "quiz": { + "x": 0.86, + "y": 89.31, + "w": 44.3, + "h": 10.69 + } + }, + "08-badges": { + "amber": { + "x": 0.86, + "y": 52.04, + "w": 27.11, + "h": 8.5 + }, + "analysis": { + "x": 0.86, + "y": 69.29, + "w": 30, + "h": 8.5 + }, + "red": { + "x": 27.49, + "y": 34.79, + "w": 22.3, + "h": 8.5 + } + }, + "09-help": { + "modal": { + "x": 32.19, + "y": 28.47, + "w": 35.63, + "h": 43.06 + } + }, + "10-narrow": { + "panel": { + "x": 3.39, + "y": 15.89, + "w": 93.23, + "h": 7.5 + } + }, + "11-timeout": { + "notice": { + "x": 55.02, + "y": 15.07, + "w": 41.94, + "h": 3.81 + } + }, + "12-lockout": { + "banner": { + "x": 52.73, + "y": 22.94, + "w": 18.17, + "h": 3.81 + } + }, + "13-instructor-login": { + "password": { + "x": 43.62, + "y": 50.81, + "w": 18.13, + "h": 4.13 + } + }, + "14-instructor-quiz": { + "toolbar": { + "x": 29.32, + "y": 10.22, + "w": 11.95, + "h": 6.06 + }, + "danger": { + "x": 72.81, + "y": 10.13, + "w": 17.28, + "h": 6.25 + }, + "answer": { + "x": 31.48, + "y": 34.37, + "w": 5.32, + "h": 15.77 + }, + "options": { + "x": 36.33, + "y": 34.37, + "w": 24.65, + "h": 15.77 + } + }, + "15-student-answers": { + "toggle": { + "x": 42.05, + "y": 10.44, + "w": 13.1, + "h": 5.63 + }, + "answers": { + "x": 32.06, + "y": 39.78, + "w": 16.84, + "h": 9.2 + } + }, + "16-analysis-instructor": { + "entries": { + "x": 17.96, + "y": 42.53, + "w": 32.26, + "h": 7.1 + } + }, + "17-scores": { + "answered": { + "x": 32.13, + "y": 49.09, + "w": 6.31, + "h": 9.69 + }, + "correct": { + "x": 37.97, + "y": 49.09, + "w": 11.59, + "h": 9.69 + }, + "first": { + "x": 49.08, + "y": 49.09, + "w": 8.35, + "h": 9.69 + }, + "tries": { + "x": 64.31, + "y": 49.66, + "w": 6.07, + "h": 3.13 + }, + "analysis": { + "x": 64.31, + "y": 54.91, + "w": 9.77, + "h": 3.31 + } + }, + "18-reset-pin": { + "password": { + "x": 39.88, + "y": 53.75, + "w": 20.23, + "h": 5.63 + }, + "confirm": { + "x": 52.45, + "y": 60.62, + "w": 7.67, + "h": 5.19 + } + }, + "19-erase": { + "phrase": { + "x": 32.11, + "y": 48.13, + "w": 35.78, + "h": 5.63 + }, + "password": { + "x": 32.11, + "y": 58.63, + "w": 35.78, + "h": 5.63 + } + }, + "20-forged-flag": { + "panel": { + "x": 54, + "y": 10.13, + "w": 43.97, + "h": 14.31 + }, + "table": { + "x": 0.86, + "y": 42.99, + "w": 31.13, + "h": 29.97 + } + } +}; +window.WALKTHROUGH_CSV = "Service ID,Name,Release,Page ID,Question Index,Answer,Success,Timestamp,Kind,Attempts,First Attempt Correct\n30012345,Jo Bloggs,TRV Connectors Autumn 2025,quiz-mcq,0,1,true,2026-10-31T17:40:36.019Z,quiz,2,false\n30012345,Jo Bloggs,TRV Connectors Autumn 2025,quiz-mcq,1,2,false,2026-10-31T17:40:36.427Z,quiz,1,false\n30012345,Jo Bloggs,TRV Connectors Autumn 2025,quiz-numeric,4,1500,true,2026-10-31T17:40:37.861Z,quiz,1,true\n30012345,Jo Bloggs,TRV Connectors Autumn 2025,analysis-tactical,Threat Assessment,High: submarine contact bearing 045,,2026-10-31T17:40:39.742Z,analysis,,\n30012345,Jo Bloggs,TRV Connectors Autumn 2025,analysis-tactical,Recommended Action,Turn away and increase speed,,2026-10-31T17:40:39.742Z,analysis,,\n30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,0,1,true,2026-10-31T17:40:44.530Z,quiz,1,true\n30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,1,3,true,2026-10-31T17:40:44.790Z,quiz,1,true\n30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,2,1,true,2026-10-31T17:40:45.048Z,quiz,1,true\n30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,3,1,true,2026-10-31T17:40:45.306Z,quiz,1,true\n30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,4,2,true,2026-10-31T17:40:45.566Z,quiz,1,true"; diff --git a/docs/walkthrough/index.html b/docs/walkthrough/index.html new file mode 100644 index 0000000..384362d --- /dev/null +++ b/docs/walkthrough/index.html @@ -0,0 +1,494 @@ + + + + + +Sonar Quiz Walkthrough + + + + +
+ Sonar Quiz System + + +
+
+
+ + + + + + diff --git a/docs/walkthrough/sample-export.csv b/docs/walkthrough/sample-export.csv new file mode 100644 index 0000000..ab590d6 --- /dev/null +++ b/docs/walkthrough/sample-export.csv @@ -0,0 +1,11 @@ +Service ID,Name,Release,Page ID,Question Index,Answer,Success,Timestamp,Kind,Attempts,First Attempt Correct +30012345,Jo Bloggs,TRV Connectors Autumn 2025,quiz-mcq,0,1,true,2026-10-31T17:40:36.019Z,quiz,2,false +30012345,Jo Bloggs,TRV Connectors Autumn 2025,quiz-mcq,1,2,false,2026-10-31T17:40:36.427Z,quiz,1,false +30012345,Jo Bloggs,TRV Connectors Autumn 2025,quiz-numeric,4,1500,true,2026-10-31T17:40:37.861Z,quiz,1,true +30012345,Jo Bloggs,TRV Connectors Autumn 2025,analysis-tactical,Threat Assessment,High: submarine contact bearing 045,,2026-10-31T17:40:39.742Z,analysis,, +30012345,Jo Bloggs,TRV Connectors Autumn 2025,analysis-tactical,Recommended Action,Turn away and increase speed,,2026-10-31T17:40:39.742Z,analysis,, +30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,0,1,true,2026-10-31T17:40:44.530Z,quiz,1,true +30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,1,3,true,2026-10-31T17:40:44.790Z,quiz,1,true +30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,2,1,true,2026-10-31T17:40:45.048Z,quiz,1,true +30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,3,1,true,2026-10-31T17:40:45.306Z,quiz,1,true +30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,4,2,true,2026-10-31T17:40:45.566Z,quiz,1,true \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index b4b8c19..03e1c88 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,6 +19,7 @@ export default tseslint.config( 'test-results/**', 'dita/**', 'dita-demo/**', + 'docs/walkthrough/img/**', // generated by scripts/capture-walkthrough.mjs '.rewrite-reference/**', ], }, diff --git a/package.json b/package.json index 437e341..593f75e 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "size-check": "node scripts/check-bundle-size.js", "check:answer-leak": "node scripts/check-answer-leak.js", "package:template": "bash scripts/package-template.sh", + "walkthrough:capture": "node scripts/capture-walkthrough.mjs", "test:coverage": "vitest run --coverage", "test:coverage:unit": "vitest run --coverage", "test:coverage:integration": "vitest run --config vitest.integration.config.ts --coverage", diff --git a/scripts/capture-walkthrough.mjs b/scripts/capture-walkthrough.mjs new file mode 100644 index 0000000..186d86c --- /dev/null +++ b/scripts/capture-walkthrough.mjs @@ -0,0 +1,353 @@ +#!/usr/bin/env node + +/** + * Capture the screenshots for docs/walkthrough/ (the slide walkthrough). + * + * Drives the DITA demo in headless Chromium through the student and + * instructor journeys, saves one PNG per slide into docs/walkthrough/img/, + * and records where each annotation pin goes (measured from the live + * elements) in docs/walkthrough/img/shots.js, so pins stay on target after + * a UI change. Captions live in docs/walkthrough/index.html. + * + * Usage: + * npm run build:dita:plain # put the current bundle into dita-demo/ + * npm run walkthrough:capture + * git checkout -- dita-demo # do not commit the locally built bundle + * + * Set PW_CHROMIUM_PATH to use a specific Chromium binary. + */ + +import { chromium } from '@playwright/test'; +import { mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const DEMO = `file://${join(ROOT, 'dita-demo')}`; +const OUT = join(ROOT, 'docs', 'walkthrough', 'img'); +const WIDTH = 1280; +const HEIGHT = 800; +const PASSWORD = 'pwd'; // demo instructor password (hash in dita-demo) + +/** Marked boxes per shot: { shotId: { pinId: { x, y, w, h } } } in % of the image. */ +const shots = {}; + +/** Sample CSV export, embedded in shots.js for the export slide. */ +let csvText = ''; + +/** + * Screenshot the viewport and measure pins. + * + * @param {import('@playwright/test').Page} page + * @param {string} id - Shot id, also the file name + * @param {Record} pins - Pin id β†’ element + */ +async function shot(page, id, pins = {}) { + await page.waitForTimeout(350); + const viewport = page.viewportSize() ?? { width: WIDTH, height: HEIGHT }; + const positions = {}; + for (const [pinId, locator] of Object.entries(pins)) { + const box = await locator.first().boundingBox(); + if (!box) { + throw new Error(`Pin ${id}/${pinId}: element not visible`); + } + // The deck outlines this box and puts the pin number on its top-left + // corner, so the annotation does not cover what it points at + const left = Math.max(box.x - 3, 0); + const top = Math.max(box.y - 3, 0); + const right = Math.min(box.x + box.width + 3, viewport.width); + const bottom = Math.min(box.y + box.height + 3, viewport.height); + const pct = (value, of) => +((value / of) * 100).toFixed(2); + positions[pinId] = { + x: pct(left, viewport.width), + y: pct(top, viewport.height), + w: pct(right - left, viewport.width), + h: pct(bottom - top, viewport.height), + }; + } + shots[id] = positions; + await page.screenshot({ path: join(OUT, `${id}.png`) }); + console.log(` βœ“ ${id}`); +} + +async function waitReady(page) { + await page.locator('qd-login[data-ready]').waitFor({ state: 'attached' }); + await page.waitForTimeout(300); +} + +async function open(page, file) { + await page.goto(`${DEMO}/${file}`); + await waitReady(page); +} + +/** Fill and submit the student form; completes the create-and-confirm step. */ +async function loginStudent(page, name, serviceId, pin) { + const form = page.locator('qd-login form.login-form'); + await form.locator('input[name="name"]').fill(name); + await form.locator('input[name="serviceId"]').fill(serviceId); + await form.locator('input[name="pin"]').fill(pin); + await page.waitForTimeout(450); // registration lookup is debounced + await form.locator('button.login-btn').click(); + const confirm = form.locator('button.login-btn:has-text("Confirm")'); + if (await confirm.isVisible({ timeout: 1500 }).catch(() => false)) { + await form.locator('input[name="pin"]').fill(pin); + await form.locator('button.login-btn').click(); + } + await page.locator('qd-status').waitFor({ state: 'visible' }); +} + +async function logout(page) { + await page.locator('qd-status .logout-button').click(); + await page.locator('qd-login').waitFor({ state: 'visible' }); +} + +async function loginInstructor(page) { + await page.locator('qd-login button').filter({ hasText: /instructor/i }).click(); + await page.locator('qd-modal[open] input[type="password"]').fill(PASSWORD); + await page.locator('qd-modal[open] button[type="submit"]').click(); + await page.locator('qd-instructor').waitFor({ state: 'visible' }); +} + +async function main() { + mkdirSync(OUT, { recursive: true }); + const browser = await chromium.launch( + process.env.PW_CHROMIUM_PATH ? { executablePath: process.env.PW_CHROMIUM_PATH } : {}, + ); + const context = await browser.newContext({ + viewport: { width: WIDTH, height: HEIGHT }, + acceptDownloads: true, + }); + // The published template no longer has a search box (SRD 013 ANS-01), but + // dita-demo/ predates that template; hide it so screenshots match. + await context.addInitScript(() => { + document.addEventListener('DOMContentLoaded', () => { + const style = document.createElement('style'); + style.textContent = '.wh_search_input { display: none !important; }'; + document.head.appendChild(style); + }); + }); + const page = await context.newPage(); + + console.log('Student journey'); + await open(page, 'Pages/quiz-mcq.html'); + await page.evaluate(() => { + sessionStorage.clear(); + indexedDB.deleteDatabase('BrowserTestDB'); + }); + await open(page, 'Pages/quiz-mcq.html'); + await shot(page, '01-logged-out', { + panel: page.locator('qd-login'), + help: page.locator('qd-login qd-help-trigger'), + notice: page.locator('.qd-login-notice'), + table: page.locator('table.qd-quiz'), + }); + + const form = page.locator('qd-login form.login-form'); + await form.locator('input[name="name"]').fill('J'); + await form.locator('input[name="serviceId"]').fill('x'); + await form.locator('input[name="pin"]').fill('12'); + await form.locator('input[name="name"]').focus(); + await shot(page, '02-validation', { + hint: page.locator('qd-login .hint-message'), + field: page.locator('qd-login input[name="serviceId"]'), + button: page.locator('qd-login button.login-btn'), + }); + + await form.locator('input[name="name"]').fill('Jo Bloggs'); + await form.locator('input[name="serviceId"]').fill('30012345'); + await form.locator('input[name="pin"]').fill('1234'); + await page.waitForTimeout(500); + await shot(page, '03-create', { button: page.locator('qd-login button.login-btn') }); + await form.locator('button.login-btn').click(); + await shot(page, '04-confirm', { + pin: page.locator('qd-login input[name="pin"]'), + hint: page.locator('qd-login .hint-message'), + }); + await form.locator('input[name="pin"]').fill('1234'); + await form.locator('button.login-btn').click(); + await page.locator('qd-status').waitFor({ state: 'visible' }); + + const selects = page.locator('table.qd-quiz select'); + await selects.nth(0).selectOption('2'); // wrong first ... + await page.waitForTimeout(400); + await selects.nth(0).selectOption('1'); // ... then right: 2 tries + await page.waitForTimeout(400); + await selects.nth(1).selectOption('2'); // wrong + await page.waitForTimeout(600); + await shot(page, '05-mcq', { + progress: page.locator('qd-status .progress-text'), + header: page.locator('table.qd-quiz thead th').nth(1), + correct: page.locator('.qd-answer-correct .qd-answer-feedback'), + incorrect: page.locator('.qd-answer-incorrect .qd-answer-feedback'), + }); + + await open(page, 'Pages/quiz-numeric.html'); + const inputs = page.locator('table.qd-quiz input'); + await inputs.nth(4).fill('1500'); + await inputs.nth(4).press('Tab'); + await inputs.nth(0).fill('abc'); + await page.waitForTimeout(500); + await shot(page, '06-numeric', { + error: page.locator('.qd-input-error'), + correct: page.locator('.qd-answer-correct .qd-answer-feedback'), + }); + + await open(page, 'Pages/analysis-tactical.html'); + const cells = page.locator('table.qd-analysis td.interactive'); + await cells.nth(0).click(); + await page.keyboard.type('High: submarine contact bearing 045'); + await cells.nth(1).click(); + await page.keyboard.type('Turn away and increase speed'); + await page.waitForTimeout(900); + await shot(page, '07-analysis', { + label: page.locator('table.qd-analysis td').first(), + cell: cells.nth(0), + quiz: page.locator('table.qd-quiz'), + }); + + await open(page, 'page-index.html'); + await shot(page, '08-badges', { + amber: page.locator('a.quizPageBtn[href*="quiz-mcq"]'), + analysis: page.locator('a.quizPageBtn[href*="analysis-tactical"]'), + red: page.locator('a.quizPageBtn[href*="gram-1"]'), + }); + + await page.locator('qd-status qd-help-trigger').click(); + await shot(page, '09-help', { modal: page.locator('.qd-help-content') }); + await page.keyboard.press('Escape'); + + await page.setViewportSize({ width: 768, height: 900 }); + await open(page, 'Pages/quiz-mcq.html'); + await shot(page, '10-narrow', { panel: page.locator('qd-status') }); + await page.setViewportSize({ width: WIDTH, height: HEIGHT }); + + // Idle timeout: age the session, then reload + await page.evaluate(() => { + const session = JSON.parse(sessionStorage.getItem('qd/session')); + session.expiresAt = new Date(Date.now() - 1000).toISOString(); + sessionStorage.setItem('qd/session', JSON.stringify(session)); + }); + await open(page, 'Pages/quiz-mcq.html'); + await shot(page, '11-timeout', { notice: page.locator('qd-login .notice-message') }); + + // A second student, right first time, for the instructor views + await loginStudent(page, 'Sam Smith', '30067890', '5678'); + for (const [i, option] of ['1', '3', '1', '1', '2'].entries()) { + await selects.nth(i).selectOption(option); + await page.waitForTimeout(250); + } + await page.waitForTimeout(500); + await logout(page); + + // Lockout after three wrong PINs + for (let i = 0; i < 3; i++) { + await form.locator('input[name="name"]').fill('Jo Bloggs'); + await form.locator('input[name="serviceId"]').fill('30012345'); + await form.locator('input[name="pin"]').fill('9999'); + await page.waitForTimeout(450); + await form.locator('button.login-btn').click(); + await page.waitForTimeout(500); + } + await shot(page, '12-lockout', { banner: page.locator('qd-login .lockout-message') }); + + console.log('Instructor journey'); + await open(page, 'Pages/quiz-mcq.html'); + await page.locator('qd-login button').filter({ hasText: /instructor/i }).click(); + await shot(page, '13-instructor-login', { + password: page.locator('qd-modal[open] input[type="password"]'), + }); + await page.locator('qd-modal[open] input[type="password"]').fill(PASSWORD); + await page.locator('qd-modal[open] button[type="submit"]').click(); + await page.locator('qd-instructor').waitFor({ state: 'visible' }); + await shot(page, '14-instructor-quiz', { + toolbar: page.locator('qd-instructor .instructor-title'), + danger: page.locator('qd-instructor .danger-zone'), + answer: page.locator('table.qd-quiz tbody td').nth(1), + options: page.locator('table.qd-quiz tbody td').nth(2), + }); + + await page.locator('qd-instructor input[type="checkbox"]').check(); + await page.waitForTimeout(700); + await shot(page, '15-student-answers', { + toggle: page.locator('qd-instructor .toggle-label'), + answers: page.locator('qd-student-answers').first(), + }); + + await open(page, 'Pages/analysis-tactical.html'); + await page.waitForTimeout(500); + await shot(page, '16-analysis-instructor', { + entries: page.locator('qd-student-entries').first(), + }); + + await page.locator('qd-instructor button', { hasText: 'View All Scores' }).click(); + await page.locator('qd-modal[open]').waitFor(); + const jo = page.locator('qd-modal[open] tr.student-row', { hasText: 'Jo Bloggs' }); + await shot(page, '17-scores', { + answered: jo.locator('td').nth(2), + correct: jo.locator('td').nth(3), + first: jo.locator('td').nth(4), + tries: jo.locator('.answer-badge', { hasText: 'tries' }), + analysis: jo.locator('.analysis-badge'), + }); + await page.keyboard.press('Escape'); + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.locator('qd-instructor-export button').first().click(), + ]); + const csvPath = join(OUT, '..', 'sample-export.csv'); + await download.saveAs(csvPath); + csvText = readFileSync(csvPath, 'utf8'); + + await page.locator('qd-instructor button', { hasText: 'Reset PINs' }).click(); + await page.locator('qd-modal[open] button', { hasText: 'Reset' }).first().click(); + await page.locator('qd-modal[open] input[type="password"]').waitFor(); + await shot(page, '18-reset-pin', { + password: page.locator('qd-modal[open] input[type="password"]'), + confirm: page.locator('qd-modal[open] .confirm-btn'), + }); + await page.locator('qd-modal[open] .cancel-btn').click(); + await page.keyboard.press('Escape'); + + await page.locator('qd-instructor-manage button').first().click(); + await page.locator('.qd-manage-modal-overlay input[type="text"]').fill('DELETE ALL DATA'); + await shot(page, '19-erase', { + phrase: page.locator('.qd-manage-modal-overlay input[type="text"]'), + password: page.locator('.qd-manage-modal-overlay input[type="password"]'), + }); + await page.keyboard.press('Escape'); + + console.log('Shared-machine check'); + const forged = await browser.newContext({ viewport: { width: WIDTH, height: HEIGHT } }); + await forged.addInitScript(() => { + document.addEventListener('DOMContentLoaded', () => { + const style = document.createElement('style'); + style.textContent = '.wh_search_input { display: none !important; }'; + document.head.appendChild(style); + }); + }); + const intruder = await forged.newPage(); + await open(intruder, 'Pages/quiz-mcq.html'); + await intruder.evaluate(() => sessionStorage.setItem('qd/instructor', 'true')); + await open(intruder, 'Pages/quiz-mcq.html'); + await shot(intruder, '20-forged-flag', { + panel: intruder.locator('qd-login'), + table: intruder.locator('table.qd-quiz'), + }); + + await browser.close(); + + writeFileSync( + join(OUT, 'shots.js'), + '// Generated by scripts/capture-walkthrough.mjs. Pin positions in % of each image.\n' + + `window.WALKTHROUGH_SHOTS = ${JSON.stringify(shots, null, 2)};\n` + + // The deck opens from file://, where it cannot fetch the CSV itself + `window.WALKTHROUGH_CSV = ${JSON.stringify(csvText)};\n`, + ); + console.log(`\nWrote ${Object.keys(shots).length} screenshots and pin positions to ${OUT}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/components/qd-instructor/qd-instructor-export.ts b/src/components/qd-instructor/qd-instructor-export.ts index d778059..b269b72 100644 --- a/src/components/qd-instructor/qd-instructor-export.ts +++ b/src/components/qd-instructor/qd-instructor-export.ts @@ -65,7 +65,8 @@ export class QdInstructorExport extends LitElement { for (const [pageId, pageData] of Object.entries(student.pages)) { const answers = pageData.answers || []; answers.forEach((answer, index) => { - if (answer) { + // Skipped questions are padded with empty placeholders; not answers + if (answer && answer.answer.trim() !== '') { rows.push( this.csvRow([ ...who, diff --git a/src/components/qd-login.styles.ts b/src/components/qd-login.styles.ts index c2d0662..1b43bba 100644 --- a/src/components/qd-login.styles.ts +++ b/src/components/qd-login.styles.ts @@ -124,6 +124,8 @@ export const loginStyles = css` .hint-message { width: 100%; + /* Padding inside the 100% width, so long messages stay in the panel */ + box-sizing: border-box; color: #555; font-size: 11px; margin-top: 3px; @@ -145,6 +147,8 @@ export const loginStyles = css` .error-message { width: 100%; + /* Padding inside the 100% width, so long messages stay in the panel */ + box-sizing: border-box; color: #d32f2f; font-size: 11px; margin-top: 3px; @@ -156,6 +160,8 @@ export const loginStyles = css` .lockout-message { width: 100%; + /* Padding inside the 100% width, so long messages stay in the panel */ + box-sizing: border-box; color: #f57c00; font-size: 11px; margin-top: 3px; diff --git a/src/components/qd-scores-modal.ts b/src/components/qd-scores-modal.ts index b8b62f4..9d39979 100644 --- a/src/components/qd-scores-modal.ts +++ b/src/components/qd-scores-modal.ts @@ -208,17 +208,24 @@ export class QdScoresModal extends LitElement { >${this.analysisSummary(pageData.analysis)}` : ''} - ${pageData.answers.map( - (answer, idx) => html` - - Q${idx + 1}: - ${answer?.answer ?? 'β€”'}${answer?.attempts && answer.attempts > 1 - ? ` (${answer.attempts} tries)` - : ''} - - `, + ${pageData.answers.map((answer, idx) => + // Skipped questions are padded with empty + // placeholders; they are not wrong answers + !answer || answer.answer.trim() === '' + ? '' + : html` + + Q${idx + 1}: + ${answer?.answer ?? 'β€”'}${answer?.attempts && + answer.attempts > 1 + ? ` (${answer.attempts} tries)` + : ''} + + `, )} diff --git a/src/enhancers/quiz-table.ts b/src/enhancers/quiz-table.ts index 12a18e0..c6fd29a 100644 --- a/src/enhancers/quiz-table.ts +++ b/src/enhancers/quiz-table.ts @@ -469,5 +469,9 @@ export function resetQuizTableToNonInteractive(table: HTMLTableElement): void { // Remove interactive class removeClass(table, 'qd-quiz-interactive'); + // Nobody is logged in now. The table's own logout listener, which would + // show the prompt, was removed above before it could run (UX-05) + setLoginNotice(table, true); + info('Quiz table reset to non-interactive mode'); } diff --git a/tests/unit/components/qd-instructor/qd-instructor-export.test.ts b/tests/unit/components/qd-instructor/qd-instructor-export.test.ts index a953761..dd91a50 100644 --- a/tests/unit/components/qd-instructor/qd-instructor-export.test.ts +++ b/tests/unit/components/qd-instructor/qd-instructor-export.test.ts @@ -286,3 +286,35 @@ describe('qd-instructor-export attempts (SRD 013 SCO-06, SCO-07)', () => { el.remove(); }); }); + +describe('qd-instructor-export skipped questions', () => { + it('exports no rows for padded placeholders of skipped questions', async () => { + const el = document.createElement('qd-instructor-export'); + document.body.appendChild(el); + el.students = [ + { + schema: 3, + docId: '', + serviceId: 'S1', + name: 'Al', + release: 'R1', + attempted: 1, + correct: 1, + updated: '', + pages: { + 'quiz-numeric': { + state: 'incomplete', + answers: [ + { answer: '', success: false, timestamp: 't0' }, + { answer: '1500', success: true, timestamp: 't1', attempts: 1, firstSuccess: true }, + ], + }, + }, + }, + ]; + await el.updateComplete; + const rows = el.generateCSV().split('\n').slice(1); + expect(rows).toEqual(['S1,Al,R1,quiz-numeric,1,1500,true,t1,quiz,1,true']); + el.remove(); + }); +}); diff --git a/tests/unit/components/qd-scores-modal.test.ts b/tests/unit/components/qd-scores-modal.test.ts index 28a4501..b268f58 100644 --- a/tests/unit/components/qd-scores-modal.test.ts +++ b/tests/unit/components/qd-scores-modal.test.ts @@ -417,3 +417,45 @@ describe('qd-scores-modal analysis summary (SRD 013 ANA-05)', () => { expect(text).toContain('Analysis: 2 of 6 cells'); }); }); + +describe('qd-scores-modal unanswered questions', () => { + afterEach(() => { + document.querySelectorAll('qd-scores-modal, body > qd-modal').forEach((el) => el.remove()); + }); + + it('shows no badge for questions skipped before a later one was answered', async () => { + // Answering Q3 first pads Q1 and Q2 with empty placeholders; they are not + // wrong answers and must not show as red "Q1:" badges (found in the walkthrough) + const el = document.createElement('qd-scores-modal'); + el.students = [ + { + schema: 3, + docId: '', + release: 'R1', + serviceId: '30012345', + name: 'Jo', + attempted: 1, + correct: 1, + updated: '', + pages: { + 'quiz-numeric': { + state: 'incomplete', + answers: [ + { answer: '', success: false, timestamp: '' }, + { answer: '', success: false, timestamp: '' }, + { answer: '1500', success: true, timestamp: '', attempts: 1, firstSuccess: true }, + ], + }, + }, + }, + ]; + document.body.appendChild(el); + el.open = true; + await el.updateComplete; + await new Promise((r) => requestAnimationFrame(r)); + const badges = Array.from(document.querySelectorAll('qd-modal[open] .answer-badge')).map((b) => + b.textContent?.replace(/\s+/g, ' ').trim(), + ); + expect(badges).toEqual(['Q3: 1500']); + }); +}); diff --git a/tests/unit/enhancers/quiz-table-a11y.test.ts b/tests/unit/enhancers/quiz-table-a11y.test.ts index bfdce57..0e148d5 100644 --- a/tests/unit/enhancers/quiz-table-a11y.test.ts +++ b/tests/unit/enhancers/quiz-table-a11y.test.ts @@ -12,7 +12,11 @@ vi.mock('../../../src/enhancers/quiz-answer-persistence.js', async (importOrigin return { ...actual, handleAnswerInput: handleAnswerInputMock }; }); -import { enhanceQuizTable, getQuizTableMetadata } from '../../../src/enhancers/quiz-table.js'; +import { + enhanceQuizTable, + getQuizTableMetadata, + resetQuizTableToNonInteractive, +} from '../../../src/enhancers/quiz-table.js'; import { revealInstructorAnswers } from '../../../src/enhancers/instructor-answer-reveal.js'; import { applyValidationStyling, @@ -105,6 +109,17 @@ describe('quiz table accessibility and usability', () => { getQuizTableMetadata(table)?.cleanupInstructorListeners?.(); }); + it('shows the prompt after logout when the coordinator resets the table', () => { + // On logout the event coordinator resets tables, which removes the + // table's own logout listener before it can run (found in the walkthrough) + const table = numericTable(); + enhanceQuizTable(table, { interactive: false }); + startSession(); + enhanceQuizTable(table, { interactive: true, pageId: 'quiz-numeric' }); + resetQuizTableToNonInteractive(table); + expect(document.querySelector('.qd-login-notice')?.textContent).toBe(LOGIN_NOTICE_TEXT); + }); + it('removes the prompt and restores the author header for instructors (UX-04)', () => { const table = numericTable(); enhanceQuizTable(table, { interactive: false });