forked from wangziqi/ruoyi-vue-pro
feat(education): complete student core loop delivery
This commit is contained in:
5
tools/education-student-harness/.gitignore
vendored
Normal file
5
tools/education-student-harness/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
artifacts/
|
||||
*.har
|
||||
*.log
|
||||
.DS_Store
|
||||
node_modules/
|
||||
22
tools/education-student-harness/README.md
Normal file
22
tools/education-student-harness/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Browser acceptance harness
|
||||
|
||||
This directory contains a local-only student client and an acceptance suite. It has no lockfile or vendored browser binaries: do not install from the network during normal repository checks.
|
||||
|
||||
## Commands
|
||||
|
||||
From this directory:
|
||||
|
||||
```bash
|
||||
npm run smoke # dependency-free route smoke test
|
||||
npm run contract # dependency-free HTTP and adapter tests
|
||||
npm run browser:if-available # runs Playwright only when it is already resolvable
|
||||
npm run browser # explicit Playwright command, requires an existing install
|
||||
```
|
||||
|
||||
The browser suite starts `server.js` itself, uses Chromium headlessly, and writes screenshots/traces to `artifacts/` (gitignored). It is intentionally not reported as passing when Playwright or its browser binary is unavailable.
|
||||
|
||||
To run against a real application instead of the deterministic local server, set `BASE_URL`; the server is then not started and the supplied token must be accepted by that application.
|
||||
|
||||
Required coverage includes desktop and H5 viewport core loops, timeout-after-commit with same-key retry, reload/current recovery, submit/report/wrong/favorite, logout, tenant/student isolation, and a request guard installed before navigation. The guard aborts every non-loopback request and any URL containing Scalar, Supabase, or provider-token patterns.
|
||||
|
||||
The dependency-free smoke route uses only Node built-ins and starts the local harness on loopback. It is the minimum check for environments without Playwright.
|
||||
143
tools/education-student-harness/acceptance.spec.js
Normal file
143
tools/education-student-harness/acceptance.spec.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// @ts-check
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const token = (name) => name;
|
||||
const LOOPBACK = /^https?:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?(?:\/|$)/i;
|
||||
const FORBIDDEN = /(scalar|supabase|(?:sk|pk|anon|service)[_-]?key|api[_-]?key|access[_-]?token|provider[_-]?token|anthropic|openai|gemini|deepseek)/i;
|
||||
|
||||
function installRequestGuard(page) {
|
||||
const blocked = [];
|
||||
const allowedViolations = [];
|
||||
page.route('**/*', async (route) => {
|
||||
const url = route.request().url();
|
||||
if (!LOOPBACK.test(url) || FORBIDDEN.test(url)) {
|
||||
blocked.push(`${route.request().method()} ${url}`);
|
||||
await route.abort('blockedbyclient');
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
return (expectedBlocked = 0) => {
|
||||
expect(allowedViolations, `unexpected request guard violations: ${allowedViolations.join(', ')}`).toEqual([]);
|
||||
expect(blocked.length, `expected ${expectedBlocked} blocked requests, saw ${blocked.length}`).toBe(expectedBlocked);
|
||||
};
|
||||
}
|
||||
|
||||
async function connect(page, student) {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByLabel('Local access token').fill(token(student));
|
||||
await page.getByTestId('connect').click();
|
||||
await expect(page.getByTestId('status')).toContainText(/Catalog ready|Session recovered|No active session/);
|
||||
await expect(page.getByTestId('identity-chip')).toContainText(student.includes('tenant-a') ? 'Student A1' : 'Student B1');
|
||||
}
|
||||
|
||||
async function start(page) {
|
||||
await page.getByRole('button', { name: 'Start practice' }).click();
|
||||
await expect(page.getByTestId('practice')).toContainText('Q1');
|
||||
}
|
||||
|
||||
test.describe('education student core loop', () => {
|
||||
test('request guard aborts external and provider-token URLs', async ({ page }) => {
|
||||
const checkGuard = installRequestGuard(page);
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
const blocked = await page.evaluate(async () => {
|
||||
const urls = ['https://example.invalid/scalar', 'https://provider.invalid/api?access_token=redacted'];
|
||||
return Promise.all(urls.map(async (url) => {
|
||||
try { await fetch(url); return false; } catch (_) { return true; }
|
||||
}));
|
||||
});
|
||||
checkGuard(2);
|
||||
|
||||
});
|
||||
|
||||
test('desktop recovery, submit, wrong questions, and favorites', async ({ page }) => {
|
||||
const checkGuard = installRequestGuard(page);
|
||||
await connect(page, 'tenant-a-student-1');
|
||||
await start(page);
|
||||
|
||||
const requests = [];
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/practice-session/answer')) requests.push(request);
|
||||
});
|
||||
await page.getByLabel('Database').check();
|
||||
await expect(page.getByTestId('practice')).toContainText(/Saved|Ready/);
|
||||
expect(requests.length).toBeGreaterThan(0);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByLabel('Local access token').fill('tenant-a-student-1');
|
||||
await page.getByTestId('connect').click();
|
||||
await expect(page.getByTestId('status')).toContainText(/Session recovered|Catalog ready/);
|
||||
await expect(page.getByTestId('practice')).toContainText('Database');
|
||||
|
||||
await page.getByLabel('Random delay').check();
|
||||
await page.getByLabel('Version check').check();
|
||||
await page.getByRole('button', { name: 'Submit practice' }).click();
|
||||
await expect(page.getByTestId('status')).toContainText(/Submitted|Wrong questions loaded/);
|
||||
await page.getByTestId('load-wrong').click();
|
||||
await expect(page.getByTestId('wrong')).toBeVisible();
|
||||
|
||||
const favorite = await page.evaluate(async () => (await fetch('/app-api/education/favorite/create', { method: 'POST', headers: { Authorization: 'Bearer tenant-a-student-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ targetType: 'QUESTION', targetId: 'q-a-1' }) })).json());
|
||||
expect(favorite.code).toBe(0);
|
||||
await page.getByTestId('load-favorites').click();
|
||||
await expect(page.getByTestId('favorites-list')).toBeVisible();
|
||||
await expect(page.getByTestId('favorites-list')).not.toContainText('No favorites yet.');
|
||||
await page.screenshot({ path: 'artifacts/desktop-core-loop.png', fullPage: true });
|
||||
checkGuard();
|
||||
});
|
||||
|
||||
test('H5 viewport core loop', async ({ page }) => {
|
||||
const checkGuard = installRequestGuard(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await connect(page, 'tenant-a-student-1');
|
||||
await start(page);
|
||||
await page.getByLabel('Controller').check();
|
||||
await expect(page.getByTestId('practice')).toContainText(/Saved|Ready/);
|
||||
await page.screenshot({ path: 'artifacts/h5-core-loop.png', fullPage: true });
|
||||
checkGuard();
|
||||
});
|
||||
|
||||
test('timeout after commit retries with the same idempotency key', async ({ page }) => {
|
||||
const checkGuard = installRequestGuard(page);
|
||||
await connect(page, 'tenant-a-student-1');
|
||||
const result = await page.evaluate(async () => {
|
||||
const create = await fetch('/app-api/education/practice-session/create', {
|
||||
method: 'POST', headers: { Authorization: 'Bearer tenant-a-student-1', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clientSessionId: `browser-timeout-${crypto.randomUUID()}`, collectionId: 'col-a-core', questionCount: 1 }),
|
||||
});
|
||||
const session = (await create.json()).data;
|
||||
const body = JSON.stringify({ sessionId: session.id, questionSequence: 1, selectedAnswer: 'A', idempotencyKey: 'same-key', clientSequence: 1, expectedSessionVersion: 0 });
|
||||
const first = await fetch('/app-api/education/practice-session/answer?fault=answer-timeout-after-commit', { method: 'PUT', headers: { Authorization: 'Bearer tenant-a-student-1', 'Content-Type': 'application/json' }, body });
|
||||
const retry = await fetch('/app-api/education/practice-session/answer', { method: 'PUT', headers: { Authorization: 'Bearer tenant-a-student-1', 'Content-Type': 'application/json' }, body });
|
||||
return { first: first.status, retry: retry.status, retryBody: await retry.json() };
|
||||
});
|
||||
expect(result.first).toBe(504);
|
||||
expect(result.retry).toBe(200);
|
||||
expect(result.retryBody.data.selectedAnswer).toBe('A');
|
||||
checkGuard();
|
||||
});
|
||||
|
||||
test('logout clears the in-memory student session', async ({ page }) => {
|
||||
const checkGuard = installRequestGuard(page);
|
||||
await connect(page, 'tenant-a-student-1');
|
||||
await page.getByTestId('logout').click();
|
||||
await expect(page.getByTestId('identity-chip')).toHaveText('Offline');
|
||||
await expect(page.getByTestId('status')).toHaveText('Logged out');
|
||||
checkGuard();
|
||||
});
|
||||
|
||||
test('two tenants and two students cannot see each other resources', async ({ page, request }) => {
|
||||
const checkGuard = installRequestGuard(page);
|
||||
const a = await request.get('/app-api/education/context', { headers: { Authorization: 'Bearer tenant-a-student-1' } });
|
||||
const b = await request.get('/app-api/education/context', { headers: { Authorization: 'Bearer tenant-b-student-1' } });
|
||||
expect((await a.json()).data.userId).toBe('student-a1');
|
||||
expect((await b.json()).data.userId).toBe('student-b1');
|
||||
const create = await request.post('/app-api/education/practice-session/create', { headers: { Authorization: 'Bearer tenant-a-student-1' }, data: { clientSessionId: 'isolation', collectionId: 'col-a-core', questionCount: 1 } });
|
||||
const session = (await create.json()).data;
|
||||
const stolen = await request.get(`/app-api/education/practice-session/get?id=${session.id}`, { headers: { Authorization: 'Bearer tenant-a-student-2' } });
|
||||
expect(stolen.status()).toBe(404);
|
||||
const otherTenant = await request.get('/app-api/education/questions/page?collectionId=col-a-core', { headers: { Authorization: 'Bearer tenant-b-student-1' } });
|
||||
expect((await otherTenant.json()).data.list).toHaveLength(0);
|
||||
checkGuard();
|
||||
});
|
||||
});
|
||||
34
tools/education-student-harness/adapter.js
Normal file
34
tools/education-student-harness/adapter.js
Normal file
@@ -0,0 +1,34 @@
|
||||
'use strict';
|
||||
|
||||
const API_PREFIX = '/app-api';
|
||||
|
||||
function buildRequest(path, options = {}, accessToken = '') {
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
};
|
||||
return { url: `${API_PREFIX}${path}`, options: { ...options, headers } };
|
||||
}
|
||||
|
||||
function answerCommand(session, questionSequence, selectedAnswer, idempotencyKey, clientSequence) {
|
||||
return {
|
||||
sessionId: session.id,
|
||||
questionSequence,
|
||||
selectedAnswer,
|
||||
idempotencyKey,
|
||||
clientSequence,
|
||||
expectedSessionVersion: session.sessionVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function applyAnswerResult(session, result) {
|
||||
return {
|
||||
...session,
|
||||
sessionVersion: result.sessionVersion,
|
||||
serverVersion: result.serverVersion ?? result.sessionVersion,
|
||||
acceptedSequence: Math.max(session.acceptedSequence || 0, result.acceptedSequence || 0),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { buildRequest, answerCommand, applyAnswerResult };
|
||||
19
tools/education-student-harness/adapter.test.js
Normal file
19
tools/education-student-harness/adapter.test.js
Normal file
@@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
const assert = require('assert');
|
||||
const { buildRequest, answerCommand, applyAnswerResult } = require('./adapter');
|
||||
|
||||
const request = buildRequest('/education/context', { method: 'GET' }, 'memory-token');
|
||||
assert.equal(request.url, '/app-api/education/context');
|
||||
assert.equal(request.options.headers.Authorization, 'Bearer memory-token');
|
||||
assert.equal(request.options.headers.Accept, 'application/json');
|
||||
|
||||
const session = { id: 's-1', sessionVersion: 4, acceptedSequence: 2 };
|
||||
const command = answerCommand(session, 3, 'B', 'answer-key-1', 3);
|
||||
assert.deepEqual(command, { sessionId: 's-1', questionSequence: 3, selectedAnswer: 'B', idempotencyKey: 'answer-key-1', clientSequence: 3, expectedSessionVersion: 4 });
|
||||
|
||||
const advanced = applyAnswerResult(session, { sessionVersion: 5, acceptedSequence: 3 });
|
||||
assert.equal(advanced.sessionVersion, 5);
|
||||
assert.equal(advanced.acceptedSequence, 3);
|
||||
assert.equal(applyAnswerResult(advanced, { sessionVersion: 6, acceptedSequence: 2 }).acceptedSequence, 3);
|
||||
|
||||
process.stdout.write('education student adapter unit tests passed\n');
|
||||
45
tools/education-student-harness/app.js
Normal file
45
tools/education-student-harness/app.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const API_PREFIX = '/app-api';
|
||||
let accessToken = '';
|
||||
let tenant = null;
|
||||
let currentSession = null;
|
||||
let clientSequence = 0;
|
||||
let expectedSessionVersion = 0;
|
||||
let saveState = 'idle';
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const tokenFor = () => accessToken;
|
||||
|
||||
export function buildRequest(path, options = {}, token = accessToken) {
|
||||
const headers = { Accept: 'application/json', ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) };
|
||||
return { url: `${API_PREFIX}${path}`, options: { ...options, headers } };
|
||||
}
|
||||
export function nextAnswerCommand(session, questionSequence, selectedAnswer, key = crypto.randomUUID()) {
|
||||
return { sessionId: session.id, questionSequence, selectedAnswer, idempotencyKey: key, clientSequence: (session.acceptedSequence || 0) + 1, expectedSessionVersion: session.sessionVersion ?? session.serverVersion ?? 0 };
|
||||
}
|
||||
export function applyAnswerState(session, result) { return { ...session, sessionVersion: result.sessionVersion, serverVersion: result.serverVersion ?? result.sessionVersion, acceptedSequence: Math.max(session.acceptedSequence || 0, result.acceptedSequence || 0) }; }
|
||||
|
||||
function setStatus(text, tone = 'neutral') { $('status').textContent = text; $('status').dataset.tone = tone; $('connection-dot').dataset.tone = tone; }
|
||||
function setSaveState(state, text) { saveState = state; const node = $('save-state'); if (node) { node.textContent = text; node.dataset.state = state; } }
|
||||
function requestId(response) { const id = response.headers.get('x-request-id') || response.headers.get('x-trace-id'); if (id) $('request-id').textContent = `req ${id}`; }
|
||||
function query(params = {}) { const value = new URLSearchParams(); Object.entries(params).forEach(([key, item]) => { if (item !== undefined && item !== null && item !== '') value.set(key, item); }); const result = value.toString(); return result ? `?${result}` : ''; }
|
||||
async function api(path, options = {}) { const request = buildRequest(path, options); const response = await fetch(request.url, request.options); requestId(response); const payload = await response.json().catch(() => ({})); if (!response.ok || (payload.code !== undefined && payload.code !== 0)) { const error = new Error(payload.msg || `Request failed (${response.status})`); error.status = response.status; error.data = payload.data; throw error; } return payload.data; }
|
||||
function escapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c])); }
|
||||
function button(label, handler, className = 'button button-outline') { const b = document.createElement('button'); b.type = 'button'; b.textContent = label; b.className = className; b.addEventListener('click', handler); return b; }
|
||||
function renderList(target, list, emptyText, render) { const node = $(target); node.innerHTML = ''; if (!list?.length) { node.innerHTML = `<p class="empty-state">${escapeHtml(emptyText)}</p>`; return; } list.forEach((item) => node.appendChild(render(item))); }
|
||||
function item(title, detail, action) { const node = document.createElement('article'); node.className = 'list-item'; node.innerHTML = `<div><strong>${escapeHtml(title)}</strong><span>${escapeHtml(detail || '')}</span></div>`; if (action) node.append(action); return node; }
|
||||
function renderContext(data) { tenant = data; $('identity-chip').textContent = `${data.tenantName || data.tenantId} · ${data.displayName || data.userId}`; $('context').innerHTML = `<div><dt>Tenant</dt><dd>${escapeHtml(data.tenantName || data.tenantId)}</dd></div><div><dt>Student</dt><dd>${escapeHtml(data.displayName || data.userId)}</dd></div>`; }
|
||||
async function resolveTenant() { return api('/education/tenant/resolve'); }
|
||||
async function connect(event) { event?.preventDefault(); accessToken = $('token').value.trim(); if (!accessToken) { const mobile = $('mobile').value.trim(); const password = $('password').value; if (!mobile || !password) { setStatus('Enter member credentials or a local token', 'bad'); return; } try { setStatus('Logging in…'); const login = await api('/member/auth/login', { method: 'POST', body: JSON.stringify({ mobile, password }) }, ''); accessToken = login?.accessToken || login?.token || ''; } catch (error) { setStatus(error.message, 'bad'); return; } } try { setStatus('Resolving tenant…'); await resolveTenant(); const context = await api('/education/context'); renderContext(context); setStatus('Connected', 'good'); await loadCatalog(); await loadCurrent(); } catch (error) { setStatus(error.message, 'bad'); } }
|
||||
async function loginWithCredentials() { return null; }
|
||||
async function logout() { try { if (accessToken) await api('/member/auth/logout', { method: 'POST' }); } catch (_) { /* local memory is still cleared */ } accessToken = ''; tenant = null; currentSession = null; $('identity-chip').textContent = 'Offline'; $('context').innerHTML = '<div><dt>Tenant</dt><dd>Not resolved</dd></div><div><dt>Student</dt><dd>Not authenticated</dd></div>'; renderPractice(); setStatus('Logged out', 'neutral'); }
|
||||
async function loadCatalog() { try { setStatus('Loading catalog…'); const [collections, subjects] = await Promise.all([api('/education/catalog/question-collections?limit=20'), api('/education/catalog/subjects')]); const select = $('subject-filter'); select.innerHTML = '<option value="">All subjects</option>' + (subjects || []).map((x) => `<option value="${escapeHtml(x.id)}">${escapeHtml(x.name || x.title || x.id)}</option>`).join(''); renderList('collections', collections, 'No permitted collections returned.', collectionCard); setStatus('Catalog ready', 'good'); } catch (error) { setStatus(error.message, 'bad'); } }
|
||||
function collectionCard(collection) { const article = document.createElement('article'); article.className = 'collection-card'; article.innerHTML = `<div class="collection-index">SET</div><h3>${escapeHtml(collection.name || collection.title || collection.id)}</h3><p>${escapeHtml(collection.description || 'A focused set for your next study pass.')}</p><div class="collection-meta"><span>${collection.questionCount ?? '?'} questions</span><span>${escapeHtml(collection.status || 'available')}</span></div>`; article.append(button('Start practice', () => createPractice(collection), 'button button-dark')); return article; }
|
||||
async function createPractice(collection) { try { setStatus('Creating practice…'); currentSession = await api('/education/practice-session/create', { method: 'POST', body: JSON.stringify({ clientSessionId: crypto.randomUUID(), collectionId: collection.id, questionCount: Math.min(collection.questionCount || 5, 5) }) }); clientSequence = currentSession.acceptedSequence || 0; expectedSessionVersion = currentSession.sessionVersion || 0; renderPractice(); setStatus('Practice active', 'good'); location.hash = 'practice'; } catch (error) { setStatus(error.message, 'bad'); } }
|
||||
async function loadCurrent() { try { setStatus('Checking your session…'); currentSession = await api('/education/practice-session/current'); if (currentSession) { clientSequence = currentSession.acceptedSequence || 0; expectedSessionVersion = currentSession.sessionVersion || 0; } renderPractice(); setStatus(currentSession ? 'Session recovered' : 'No active session', currentSession ? 'good' : 'neutral'); } catch (error) { setStatus(error.message, 'bad'); } }
|
||||
function renderPractice() { const host = $('practice'); host.innerHTML = ''; $('state-readout').textContent = currentSession ? `${currentSession.status} · v${currentSession.sessionVersion ?? 0}` : 'No session'; if (!currentSession) { host.innerHTML = '<p class="empty-state">No active session. Start one above.</p>'; return; } const heading = document.createElement('div'); heading.className = 'practice-head'; heading.innerHTML = `<div><span class="session-badge">${escapeHtml(currentSession.status)}</span><strong>${currentSession.questionCount || currentSession.questions?.length || 0} questions</strong></div><span id="save-state" class="save-state" data-state="idle">Ready</span>`; host.append(heading); (currentSession.questions || []).forEach((question) => { const field = document.createElement('fieldset'); field.className = 'question'; field.innerHTML = `<legend><span>Q${question.sequence}</span>${escapeHtml(question.stem || question.questionId)}</legend><div class="options">${(question.options || []).map((option, index) => { const value = String.fromCharCode(65 + index); return `<label class="option"><input type="radio" name="q-${question.sequence}" value="${value}" ${question.selectedAnswer === value ? 'checked' : ''}><span><b>${value}</b>${escapeHtml(option)}</span></label>`; }).join('')}</div>`; field.querySelectorAll('input').forEach((input) => input.addEventListener('change', () => saveAnswer(question, input.value))); host.append(field); }); if (currentSession.status === 'ACTIVE') { const actions = document.createElement('div'); actions.className = 'practice-actions'; actions.append(button('Submit practice', submitPractice, 'button button-dark')); host.append(actions); } }
|
||||
async function saveAnswer(question, answer) { const command = nextAnswerCommand({ ...currentSession, acceptedSequence: clientSequence, sessionVersion: expectedSessionVersion }, question.sequence, answer, question.pendingKey || crypto.randomUUID()); question.pendingKey = command.idempotencyKey; setSaveState('saving', 'Saving…'); try { const result = await api('/education/practice-session/answer', { method: 'PUT', body: JSON.stringify(command) }); currentSession = applyAnswerState(currentSession, result); clientSequence = currentSession.acceptedSequence; expectedSessionVersion = currentSession.sessionVersion; question.selectedAnswer = answer; setSaveState('saved', 'Saved'); $('state-readout').textContent = `${currentSession.status} · v${expectedSessionVersion}`; } catch (error) { if (error.status === 504 || error.status >= 500) { setSaveState('retrying', 'Retrying…'); try { const result = await api('/education/practice-session/answer', { method: 'PUT', body: JSON.stringify(command) }); currentSession = applyAnswerState(currentSession, result); clientSequence = currentSession.acceptedSequence; expectedSessionVersion = currentSession.sessionVersion; question.selectedAnswer = answer; setSaveState('saved', 'Saved after retry'); return; } catch (_) {} } setSaveState('failed', 'Save failed — retry by changing this answer'); setStatus(error.message, 'bad'); } }
|
||||
async function submitPractice() { if (!currentSession) return; try { setStatus('Submitting…'); const result = await api('/education/practice-session/submit', { method: 'POST', body: JSON.stringify({ sessionId: currentSession.id, idempotencyKey: crypto.randomUUID(), expectedSessionVersion }) }); currentSession.status = 'SUBMITTED'; currentSession.sessionVersion = result.sessionVersion || expectedSessionVersion + 1; expectedSessionVersion = currentSession.sessionVersion; renderPractice(); setStatus(`Submitted · ${result.score ?? '—'} correct`, 'good'); await loadWrong(); } catch (error) { setStatus(error.message, 'bad'); } }
|
||||
async function loadWrong() { try { const data = await api('/education/wrong-question/page?pageNo=1&pageSize=20'); renderList('wrong', data?.list, 'No wrong questions yet.', (x) => item(x.questionStem || x.stem || x.questionId || x.id, `${x.errorCount ?? 0} ${x.errorCount === 1 ? 'miss' : 'misses'} · ${x.masterStatus || 'unmastered'}`, x.masterStatus !== 'MASTERED' ? button('Mark mastered', () => masterWrong(x), 'button button-small') : null)); setStatus('Wrong questions loaded', 'good'); } catch (error) { setStatus(error.message, 'bad'); } }
|
||||
async function masterWrong(wrong) { try { await api('/education/wrong-question/master', { method: 'PUT', body: JSON.stringify({ id: wrong.id }) }); await loadWrong(); } catch (error) { setStatus(error.message, 'bad'); } }
|
||||
async function loadFavorites() { try { const data = await api('/education/favorite/page?pageNo=1&pageSize=20'); renderList('favorites-list', data?.list, 'No favorites yet.', (x) => item(x.questionStem || x.stem || x.targetId, x.targetType || 'QUESTION', button('Remove', () => removeFavorite(x), 'button button-small'))); setStatus('Favorites loaded', 'good'); } catch (error) { setStatus(error.message, 'bad'); } }
|
||||
async function removeFavorite(favorite) { try { await api('/education/favorite/delete', { method: 'DELETE', body: JSON.stringify({ id: favorite.id, targetId: favorite.targetId }) }); await loadFavorites(); } catch (error) { setStatus(error.message, 'bad'); } }
|
||||
$('auth-form').addEventListener('submit', connect); $('logout').addEventListener('click', logout); $('load-catalog').addEventListener('click', loadCatalog); $('load-current').addEventListener('click', loadCurrent); $('load-wrong').addEventListener('click', loadWrong); $('load-favorites').addEventListener('click', loadFavorites);
|
||||
26
tools/education-student-harness/docs/test-report.md
Normal file
26
tools/education-student-harness/docs/test-report.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Education student harness verification
|
||||
|
||||
Date: 2026-07-28
|
||||
|
||||
## Results
|
||||
|
||||
- PASS — `npm run smoke` (dependency-free loopback route smoke test).
|
||||
- PASS — `npm run contract` (dependency-free HTTP/adapter tests).
|
||||
- PASS — Node syntax checks for all harness JavaScript, including `acceptance.spec.js`.
|
||||
- PASS — `npm run browser:if-available`; Playwright Chromium was installed locally and all six acceptance tests passed.
|
||||
- PASS — `git diff --check` for harness and workflow documentation paths.
|
||||
|
||||
## Security boundary review
|
||||
|
||||
- PASS — harness server binds to `127.0.0.1`; browser guard blocks non-loopback URLs and Scalar/provider-token patterns.
|
||||
- PASS — no downloaded code, vendored binaries, copied prototype assets/classes, or external runtime requests found.
|
||||
- PASS — no Scalar URL/token or provider secret found; screenshots/logs/trace artifacts are gitignored.
|
||||
- PASS — identity and tenant are derived from bearer-token server context; resource ownership checks cover tenant and student.
|
||||
- PASS — pre-submit question responses omit answer and explanation; submitted reports expose them only after submission.
|
||||
- PASS — production backend files were not changed by this harness workflow (existing unrelated production changes remain outside this review scope).
|
||||
|
||||
## Remaining limitations
|
||||
|
||||
- Local deterministic harness browser acceptance is complete; it is not a substitute for the real Student Web/H5 application.
|
||||
- Real Student Web/H5 lint, type checking, tests, production build, and browser E2E remain blocked because those sources are not in this workspace.
|
||||
- Real Scalar read-only smoke, Pilot deployment configuration, production database migration, rollback, and trace-to-upstream observability evidence require a deployment environment and approved credentials.
|
||||
36
tools/education-student-harness/endpoint-matrix.md
Normal file
36
tools/education-student-harness/endpoint-matrix.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Endpoint matrix
|
||||
|
||||
All paths below are browser-relative `/app-api` routes. The server derives authenticated user and tenant context; the harness never sends those as business fields.
|
||||
|
||||
| Capability | Method | Relative route | Request/query used by harness | Expected data shape | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| Tenant resolution | GET | `/education/tenant/resolve` | deployment-specific resolver query; not called automatically | tenant resolution object | Use server entry-point/domain policy; do not accept a client tenant override. |
|
||||
| Education context | GET | `/education/context` | none | `{ userId, tenantId, tenantName, displayName }` | Authenticated; verifies active tenant. |
|
||||
| Regions | GET | `/education/catalog/regions` | none | array of region objects | Catalog read gate applies. |
|
||||
| Categories | GET | `/education/catalog/categories` | `subjectId`, optional `nodeId` | array | Catalog read gate applies. |
|
||||
| Subjects | GET | `/education/catalog/subjects` | optional `regionId`, `schoolId`, `majorId`, `moduleId`, `type` | array | Catalog read gate applies. |
|
||||
| Question collections | GET | `/education/catalog/question-collections` | optional `regionId`, `entryId`, `nodeId`, `collectionType`, `limit` | array of collections | Harness uses this as the practice start list. |
|
||||
| Safe question page | GET | `/education/questions/page` | `collectionId`, `pageNo`, `pageSize` | page result `{ list, total }` | Must not include answers or explanations. |
|
||||
| Practice preview | GET | `/education/practice-config/preview` | request VO query fields | preview object | Validates criteria without creating a session. |
|
||||
| Create practice | POST | `/education/practice-session/create` | `{ clientSessionId, collectionId, nodeId?, type?, difficulty?, questionCount }` | practice session | Idempotent by client session ID. |
|
||||
| Current practice | GET | `/education/practice-session/current` | none | session or `null` | Used for refresh recovery. |
|
||||
| Practice by ID | GET | `/education/practice-session/get` | `id` | session | Ownership and tenant checks are server-side. |
|
||||
| Save answer | PUT | `/education/practice-session/answer` | `{ sessionId, questionSequence, selectedAnswer, idempotencyKey, clientSequence, expectedSessionVersion }` | answer save result with version | Idempotent and stale-write resistant. |
|
||||
| Submit practice | POST | `/education/practice-session/submit` | `{ sessionId, idempotencyKey, expectedSessionVersion }` | submit/report result | Atomic one-way transition; safe retry. |
|
||||
| Report | GET | `/education/practice-session/report` | `sessionId` | report with details | Correct answers/explanations only after submit. |
|
||||
| Report history | GET | `/education/practice-session/reports` | `pageNo`, `pageSize` | page result | Current student only. |
|
||||
| Wrong questions | GET | `/education/wrong-question/page` | `pageNo`, `pageSize`, optional `masterStatus` | page result | Current student only. |
|
||||
| Wrong question detail | GET | `/education/wrong-question/get` | `id` | detail | Includes answer/explanation after failure is recorded. |
|
||||
| Mark mastered | PUT | `/education/wrong-question/master` | `id` | boolean | Idempotent. |
|
||||
| Unmark mastered | PUT | `/education/wrong-question/unmaster` | `id` | boolean | Idempotent. |
|
||||
| Wrong-question review | POST | `/education/wrong-question/review-session` | `{ clientSessionId, wrongQuestionIds[] }` | practice session | Server validates ownership. |
|
||||
| Favorites | GET | `/education/favorite/page` | `pageNo`, `pageSize`, optional `targetType` | page result | Current student only. |
|
||||
| Favorite create | POST | `/education/favorite/create` | `{ targetType: 'QUESTION', targetId }` | favorite item | Idempotent. |
|
||||
| Favorite delete | DELETE | `/education/favorite/delete` | `{ id? or targetType, targetId? }` | boolean | Logical/idempotent removal. |
|
||||
| Favorite status | POST | `/education/favorite/status` | `{ questionIds[] }` | `{ questionIds }` | Batch status probe. |
|
||||
|
||||
## Envelope and failures
|
||||
|
||||
The project convention is a common result envelope. Successful payloads are expected under `data`; page payloads generally contain `list` and `total`. Errors should remain errors rather than becoming empty success data. Capture the server-provided request/trace ID for local investigation, but never record authorization headers or full sensitive response bodies.
|
||||
|
||||
The route prefix is intentionally `/app-api`, not a direct Scalar URL. If the local server uses another deployment prefix, adapt the reverse proxy rather than changing the harness to call Scalar.
|
||||
60
tools/education-student-harness/fixtures/README.md
Normal file
60
tools/education-student-harness/fixtures/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Fixture schemas
|
||||
|
||||
Fixtures are synthetic documentation examples, not default application data and not copies of prototype data. They model the stable fields the harness reads.
|
||||
|
||||
## `context.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"userId": 1001,
|
||||
"tenantId": 2001,
|
||||
"tenantName": "Local Pilot School",
|
||||
"displayName": "Local Pilot School"
|
||||
}
|
||||
```
|
||||
|
||||
## `question-collection.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "collection-local-001",
|
||||
"name": "Synthetic practice collection",
|
||||
"collectionType": "QUESTION_BANK",
|
||||
"questionCount": 3,
|
||||
"status": "PUBLISHED"
|
||||
}
|
||||
```
|
||||
|
||||
## `practice-session.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 9001,
|
||||
"clientSessionId": "local-session-001",
|
||||
"status": "ACTIVE",
|
||||
"questionCount": 3,
|
||||
"sessionVersion": 1,
|
||||
"questions": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"questionId": "question-local-001",
|
||||
"contentVersion": "v1",
|
||||
"stem": "Synthetic question content",
|
||||
"type": "choice",
|
||||
"options": [{ "label": "A", "content": "Synthetic option" }],
|
||||
"selectedAnswer": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## `page.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"list": [],
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
Do not add `correctAnswer`, `explanation`, access tokens, phone numbers, real names, provider identifiers, or licensed question text to pre-submission fixtures. Post-submission report examples may include answer/explanation fields only when explicitly needed to document the permitted post-submit response boundary.
|
||||
6
tools/education-student-harness/fixtures/context.json
Normal file
6
tools/education-student-harness/fixtures/context.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"userId": 1001,
|
||||
"tenantId": 2001,
|
||||
"tenantName": "Local Pilot School",
|
||||
"displayName": "Local Pilot School"
|
||||
}
|
||||
4
tools/education-student-harness/fixtures/page.json
Normal file
4
tools/education-student-harness/fixtures/page.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"list": [],
|
||||
"total": 0
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": 9001,
|
||||
"clientSessionId": "local-session-001",
|
||||
"status": "ACTIVE",
|
||||
"questionCount": 3,
|
||||
"sessionVersion": 1,
|
||||
"questions": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"questionId": "question-local-001",
|
||||
"contentVersion": "v1",
|
||||
"stem": "Synthetic question content",
|
||||
"type": "choice",
|
||||
"options": [{ "label": "A", "content": "Synthetic option" }],
|
||||
"selectedAnswer": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "collection-local-001",
|
||||
"name": "Synthetic practice collection",
|
||||
"collectionType": "QUESTION_BANK",
|
||||
"questionCount": 3,
|
||||
"status": "PUBLISHED"
|
||||
}
|
||||
40
tools/education-student-harness/index.html
Normal file
40
tools/education-student-harness/index.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Local-only student learning loop browser harness">
|
||||
<title>Study loop / education harness</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="wordmark" href="./" aria-label="Study loop home"><span class="wordmark-mark" aria-hidden="true">∴</span><span>study loop</span></a>
|
||||
<div class="topbar-actions"><span id="identity-chip" class="identity-chip" data-testid="identity-chip">Offline</span><button id="logout" class="quiet-button" type="button" data-testid="logout">Log out</button></div>
|
||||
</header>
|
||||
<main class="page-shell">
|
||||
<section class="intro" aria-labelledby="page-title">
|
||||
<div><p class="kicker">STUDENT / CORE LOOP</p><h1 id="page-title">Make one good<br><em>pass through.</em></h1><p class="intro-copy">A small, honest browser seam for finding a set, practising, and learning from the misses.</p></div>
|
||||
<div class="connection-card" aria-live="polite"><span class="connection-dot" id="connection-dot"></span><span id="status" data-testid="status">Not connected</span><span id="request-id" class="request-id">—</span></div>
|
||||
</section>
|
||||
<div class="safety-note" role="note"><span aria-hidden="true">↳</span><span><strong>Local harness.</strong> Calls stay on relative <code>/app-api</code> routes. Your token lives in memory and your tenant is always server-derived.</span></div>
|
||||
|
||||
<section class="auth-panel" id="auth-panel" aria-labelledby="auth-title">
|
||||
<div class="section-label"><span>01</span><span>Entry</span></div>
|
||||
<div class="auth-main"><div><h2 id="auth-title">Connect your study space</h2><p>Resolve the tenant, then use an existing member account.</p></div><form id="auth-form"><label for="mobile">Member login</label><div class="login-fields"><input id="mobile" type="tel" autocomplete="username" placeholder="Mobile number"><input id="password" type="password" autocomplete="current-password" placeholder="Password"><button class="button button-dark" type="submit" data-testid="connect">Log in</button></div><p class="field-help">Local stub accepts <code>tenant-a-student-1</code> as a token below, or use the server's member credentials.</p><label class="token-label" for="token">Local access token <span>(memory only, test fallback)</span></label><input id="token" type="password" autocomplete="off" placeholder="tenant-a-student-1"></form></div>
|
||||
<dl class="identity-grid" id="context" data-testid="context"><div><dt>Tenant</dt><dd>Not resolved</dd></div><div><dt>Student</dt><dd>Not authenticated</dd></div></dl>
|
||||
</section>
|
||||
|
||||
<div class="workspace">
|
||||
<nav class="side-nav" aria-label="Learning loop sections"><p class="nav-title">Your loop</p><a href="#discover" class="nav-link active"><span>01</span>Find a set</a><a href="#practice" class="nav-link"><span>02</span>Practice</a><a href="#review" class="nav-link"><span>03</span>Review</a><a href="#favorites" class="nav-link"><span>04</span>Keep close</a><p class="nav-foot">Server truth<br><span id="state-readout">No session</span></p></nav>
|
||||
<div class="content-column">
|
||||
<section class="content-section" id="discover" aria-labelledby="discover-title"><div class="section-label"><span>02</span><span>Discover</span></div><div class="section-heading"><div><h2 id="discover-title">Choose a question set</h2><p>Only published collections permitted for your space appear here.</p></div><button class="button button-outline" id="load-catalog" type="button" data-testid="load-catalog">Load catalog</button></div><fieldset class="filters"><legend class="sr-only">Catalog filters</legend><label>Subject<select id="subject-filter" data-testid="subject-filter"><option value="">All subjects</option></select></label><label>Category<select id="category-filter"><option value="">All categories</option></select></label></fieldset><div id="collections" class="collection-grid" data-testid="collections"><p class="empty-state">Connect first, then load your permitted sets.</p></div></section>
|
||||
<section class="content-section practice-section" id="active-practice" aria-labelledby="practice-title"><div class="section-label"><span>03</span><span>Active work</span></div><div class="section-heading"><div><h2 id="practice-title">Practice, without losing your place</h2><p id="practice-subtitle">Your latest accepted answer is the durable one.</p></div><button class="button button-outline" id="load-current" type="button" data-testid="reload-current">Reload current</button></div><div id="practice" class="practice-card" data-testid="practice"><p class="empty-state">No active session. Start one above.</p></div></section>
|
||||
<section class="content-section result-grid" id="review"><div class="result-panel"><div class="section-label"><span>04</span><span>Review</span></div><div class="section-heading"><div><h2>Wrong questions</h2><p>Turn a miss into the next pass.</p></div><button class="button button-outline" id="load-wrong" type="button" data-testid="load-wrong">Load</button></div><div id="wrong" class="item-list" data-testid="wrong"><p class="empty-state">Not loaded.</p></div></div><div class="result-panel" id="favorites"><div class="section-label"><span>05</span><span>Keep close</span></div><div class="section-heading"><div><h2>Favorites</h2><p>A short list worth returning to.</p></div><button class="button button-outline" id="load-favorites" type="button" data-testid="load-favorites">Load</button></div><div id="favorites-list" class="item-list" data-testid="favorites-list"><p class="empty-state">Not loaded.</p></div></div></section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer><span>Education student harness</span><a href="endpoint-matrix.md">Endpoint matrix</a><a href="fixtures/README.md">Fixture schemas</a></footer>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
76
tools/education-student-harness/package-lock.json
generated
Normal file
76
tools/education-student-harness/package-lock.json
generated
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "education-student-harness",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "education-student-harness",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.62.0.tgz",
|
||||
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.62.0.tgz",
|
||||
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.0.tgz",
|
||||
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
tools/education-student-harness/package.json
Normal file
15
tools/education-student-harness/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "education-student-harness",
|
||||
"private": true,
|
||||
"description": "Offline-safe browser acceptance harness for the education student core loop",
|
||||
"scripts": {
|
||||
"smoke": "node smoke-route.test.js",
|
||||
"contract": "node test.js && node adapter.test.js",
|
||||
"test": "npm run smoke && npm run contract && npm run browser:if-available",
|
||||
"browser": "playwright test",
|
||||
"browser:if-available": "node run-playwright-if-available.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0"
|
||||
}
|
||||
}
|
||||
25
tools/education-student-harness/playwright.config.js
Normal file
25
tools/education-student-harness/playwright.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// @ts-check
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
const port = process.env.PW_PORT || '4197';
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: /acceptance\.spec\.js$/,
|
||||
timeout: 30_000,
|
||||
fullyParallel: false,
|
||||
reporter: [['list'], ['json', { outputFile: 'artifacts/playwright-results.json' }]],
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || `http://127.0.0.1:${port}`,
|
||||
headless: true,
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'off',
|
||||
},
|
||||
webServer: process.env.BASE_URL ? undefined : {
|
||||
command: `PORT=${port} node server.js`,
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 10_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const { spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const cwd = __dirname;
|
||||
const result = spawnSync(process.execPath, ['-e', "try { require.resolve('@playwright/test'); require.resolve('playwright'); } catch (_) { process.exit(2); }"], { cwd, stdio: 'inherit' });
|
||||
if (result.status === 2) {
|
||||
process.stdout.write('Playwright unavailable; dependency-free smoke/contract checks remain available.\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const command = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||
const run = spawnSync(command, ['playwright', 'test'], { cwd, stdio: 'inherit' });
|
||||
process.exit(run.status == null ? 1 : run.status);
|
||||
80
tools/education-student-harness/server.js
Normal file
80
tools/education-student-harness/server.js
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const ROOT = __dirname;
|
||||
const PORT = Number(process.env.PORT || 4173);
|
||||
const HOST = '127.0.0.1';
|
||||
const TOKENS = {
|
||||
'tenant-a-student-1': { tenantId: 'tenant-a', userId: 'student-a1', displayName: 'Student A1', tenantName: 'Tenant Alpha' },
|
||||
'tenant-a-student-2': { tenantId: 'tenant-a', userId: 'student-a2', displayName: 'Student A2', tenantName: 'Tenant Alpha' },
|
||||
'tenant-b-student-1': { tenantId: 'tenant-b', userId: 'student-b1', displayName: 'Student B1', tenantName: 'Tenant Beta' },
|
||||
'tenant-b-student-2': { tenantId: 'tenant-b', userId: 'student-b2', displayName: 'Student B2', tenantName: 'Tenant Beta' },
|
||||
};
|
||||
const QUESTION_DATA = [
|
||||
{ id: 'q-a-1', tenantId: 'tenant-a', collectionId: 'col-a-core', stem: 'Which layer owns the API contract?', type: 'SINGLE', options: ['Controller', 'Database', 'Browser'], answer: 'A', explanation: 'The controller owns the API boundary.' },
|
||||
{ id: 'q-a-2', tenantId: 'tenant-a', collectionId: 'col-a-core', stem: 'What prevents a stale answer overwrite?', type: 'SINGLE', options: ['Version check', 'Random delay', 'Client tenant ID'], answer: 'A', explanation: 'The session version is checked atomically.' },
|
||||
{ id: 'q-a-3', tenantId: 'tenant-a', collectionId: 'col-a-core', stem: 'Which response shape is paged?', type: 'SINGLE', options: ['PageResult', 'String', 'Token'], answer: 'A', explanation: 'PageResult carries list and total.' },
|
||||
{ id: 'q-b-1', tenantId: 'tenant-b', collectionId: 'col-b-core', stem: 'Which boundary carries tenant context?', type: 'SINGLE', options: ['Auth context', 'Question stem', 'Answer text'], answer: 'A', explanation: 'Tenant context comes from authentication.' },
|
||||
{ id: 'q-b-2', tenantId: 'tenant-b', collectionId: 'col-b-core', stem: 'When are explanations visible?', type: 'SINGLE', options: ['After submit', 'Before auth', 'Never'], answer: 'A', explanation: 'Reports reveal explanations after submission.' },
|
||||
{ id: 'q-b-3', tenantId: 'tenant-b', collectionId: 'col-b-core', stem: 'Which operation is idempotent?', type: 'SINGLE', options: ['Save answer', 'Changing tenant', 'Reading a secret'], answer: 'A', explanation: 'Answer saves use an idempotency key.' },
|
||||
];
|
||||
let state;
|
||||
function resetState() {
|
||||
state = { sessions: new Map(), answers: new Map(), reports: new Map(), wrong: new Map(), favorites: new Map(), next: 1 };
|
||||
}
|
||||
resetState();
|
||||
const id = (prefix) => `${prefix}-${state.next++}`;
|
||||
const json = (res, status, data, msg = '成功') => { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Request-Id': id('req') }); res.end(JSON.stringify({ code: status >= 400 ? status : 0, msg, data: data === undefined ? null : data })); };
|
||||
const safe = (q) => { const { answer, explanation, ...result } = q; return result; };
|
||||
const page = (list, query) => ({ list, total: list.length, pageNo: Number(query.get('pageNo') || 1), pageSize: Number(query.get('pageSize') || list.length || 10) });
|
||||
function auth(req) {
|
||||
const match = /^Bearer\s+(.+)$/.exec(req.headers.authorization || '');
|
||||
return match && TOKENS[match[1]] ? TOKENS[match[1]] : null;
|
||||
}
|
||||
function body(req) { return new Promise((resolve, reject) => { let raw = ''; req.on('data', c => { raw += c; if (raw.length > 1024 * 1024) reject(new Error('body too large')); }); req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch { reject(new Error('invalid json')); } }); req.on('error', reject); }); }
|
||||
function fault(req, name) { return req.headers['x-harness-fault'] === name || new URL(req.url, 'http://127.0.0.1').searchParams.get('fault') === name; }
|
||||
function own(ctx, resource) { return resource && resource.tenantId === ctx.tenantId && resource.userId === ctx.userId; }
|
||||
function sessionView(session, submitted = false) { return { id: session.id, clientSessionId: session.clientSessionId, tenantId: session.tenantId, userId: session.userId, collectionId: session.collectionId, status: session.status, sessionVersion: session.version, serverVersion: session.version, acceptedSequence: session.acceptedSequence, questionCount: session.questions.length, questions: session.questions.map(q => ({ sequence: q.sequence, questionId: q.questionId, stem: q.stem, type: q.type, options: q.options, selectedAnswer: q.selectedAnswer || null, ...(submitted ? { answer: q.answer, explanation: q.explanation, isCorrect: q.selectedAnswer === q.answer } : {}) })) }; }
|
||||
function findSession(ctx, value) { const s = state.sessions.get(String(value)); return own(ctx, s) ? s : null; }
|
||||
function findQuestion(ctx, qid) { return QUESTION_DATA.find(q => q.id === String(qid) && q.tenantId === ctx.tenantId); }
|
||||
async function handler(req, res) {
|
||||
const url = new URL(req.url, `http://${HOST}`); const p = url.pathname;
|
||||
if (p === '/' || p === '/index.html') return serve(res, p === '/' ? '/index.html' : p);
|
||||
if (p === '/styles.css' || p === '/app.js' || p.startsWith('/fixtures/') || p === '/endpoint-matrix.md') return serve(res, p);
|
||||
if (!p.startsWith('/app-api/')) return json(res, 404, null, 'Not found');
|
||||
if (p === '/app-api/education/tenant/resolve' && req.method === 'GET') return json(res, 200, { tenantId: 'tenant-a', tenantName: 'Tenant Alpha', resolved: true });
|
||||
const ctx = auth(req); if (!ctx) return json(res, 401, null, '未认证');
|
||||
if (p === '/app-api/education/context' && req.method === 'GET') return json(res, 200, ctx);
|
||||
if (fault(req, 'upstream-failure') && p.includes('/catalog/')) return json(res, 503, null, 'upstream failure');
|
||||
if (p === '/app-api/education/catalog/regions' && req.method === 'GET') return json(res, 200, [{ id: `${ctx.tenantId}-region-1`, name: ctx.tenantName + ' Region' }]);
|
||||
if (p === '/app-api/education/catalog/categories' && req.method === 'GET') return json(res, 200, [{ id: `${ctx.tenantId}-category-1`, name: 'Core' }]);
|
||||
if (p === '/app-api/education/catalog/subjects' && req.method === 'GET') return json(res, 200, [{ id: `${ctx.tenantId}-subject-1`, name: 'Engineering' }]);
|
||||
if (p === '/app-api/education/catalog/question-collections' && req.method === 'GET') return json(res, 200, [{ id: `col-${ctx.tenantId.slice(-1)}-core`, name: 'Core Loop', questionCount: 3, status: 'AVAILABLE', tenantId: ctx.tenantId }]);
|
||||
if (p === '/app-api/education/questions/page' && req.method === 'GET') { const list = QUESTION_DATA.filter(q => q.tenantId === ctx.tenantId && (!url.searchParams.get('collectionId') || q.collectionId === url.searchParams.get('collectionId'))).map(safe); return json(res, 200, page(list, url.searchParams)); }
|
||||
if (p === '/app-api/education/practice-config/preview' && req.method === 'GET') return json(res, 200, { valid: true, questionCount: Math.min(Number(url.searchParams.get('questionCount') || 3), 3), collectionId: url.searchParams.get('collectionId') || `col-${ctx.tenantId.slice(-1)}-core` });
|
||||
if (p === '/app-api/education/practice-session/create' && req.method === 'POST') { const b = await body(req); if (!b.clientSessionId || !b.collectionId) return json(res, 400, null, 'clientSessionId and collectionId required'); const existing = [...state.sessions.values()].find(s => own(ctx, s) && s.clientSessionId === b.clientSessionId); if (existing) return json(res, 200, sessionView(existing)); const qs = QUESTION_DATA.filter(q => q.tenantId === ctx.tenantId && q.collectionId === b.collectionId).slice(0, Math.max(1, Math.min(Number(b.questionCount || 3), 3))); if (!qs.length) return json(res, 404, null, 'collection not found'); const s = { id: id('session'), tenantId: ctx.tenantId, userId: ctx.userId, clientSessionId: b.clientSessionId, collectionId: b.collectionId, status: 'ACTIVE', version: 0, acceptedSequence: 0, questions: qs.map((q, i) => ({ ...q, questionId: q.id, sequence: i + 1, selectedAnswer: null })) }; state.sessions.set(s.id, s); return json(res, 200, sessionView(s)); }
|
||||
if (p === '/app-api/education/practice-session/current' && req.method === 'GET') { const s = [...state.sessions.values()].reverse().find(s => own(ctx, s) && s.status === 'ACTIVE'); return json(res, 200, s ? sessionView(s) : null); }
|
||||
if (p === '/app-api/education/practice-session/get' && req.method === 'GET') { const s = findSession(ctx, url.searchParams.get('id')); return s ? json(res, 200, sessionView(s, s.status === 'SUBMITTED')) : json(res, 404, null, 'session not found'); }
|
||||
if (p === '/app-api/education/practice-session/answer' && req.method === 'PUT') { const b = await body(req); const s = findSession(ctx, b.sessionId); if (!s) return json(res, 404, null, 'session not found'); if (s.status !== 'ACTIVE') return json(res, 409, null, 'submitted session is immutable'); const key = `${s.id}:${b.idempotencyKey}`; if (state.answers.has(key)) { if (fault(req, 'answer-timeout-after-commit')) return json(res, 504, null, 'timeout after commit'); return json(res, 200, state.answers.get(key)); } if (b.expectedSessionVersion !== s.version) return json(res, 409, { currentVersion: s.version }, 'stale session version'); const q = s.questions.find(q => q.sequence === Number(b.questionSequence)); if (!q || typeof b.selectedAnswer !== 'string') return json(res, 400, null, 'invalid answer'); q.selectedAnswer = b.selectedAnswer; s.version++; s.acceptedSequence = Math.max(s.acceptedSequence, Number(b.clientSequence) || 0); const result = { sessionId: s.id, questionSequence: q.sequence, selectedAnswer: q.selectedAnswer, sessionVersion: s.version, serverVersion: s.version, acceptedSequence: s.acceptedSequence }; state.answers.set(key, result); if (fault(req, 'answer-timeout-after-commit')) return json(res, 504, null, 'timeout after commit'); return json(res, 200, result); }
|
||||
if (p === '/app-api/education/practice-session/submit' && req.method === 'POST') { const b = await body(req); const s = findSession(ctx, b.sessionId); if (!s) return json(res, 404, null, 'session not found'); if (s.status === 'SUBMITTED') return json(res, 200, state.reports.get(s.id)); if (b.expectedSessionVersion !== s.version) return json(res, 409, { currentVersion: s.version }, 'stale session version'); if (!b.idempotencyKey) return json(res, 400, null, 'idempotencyKey required'); const details = s.questions.map(q => ({ questionId: q.questionId, selectedAnswer: q.selectedAnswer, answer: q.answer, explanation: q.explanation, isCorrect: q.selectedAnswer === q.answer })); const report = { id: id('report'), sessionId: s.id, score: details.filter(x => x.isCorrect).length, total: details.length, details }; s.status = 'SUBMITTED'; s.version++; state.reports.set(s.id, report); details.filter(x => !x.isCorrect).forEach(x => { const k = `${ctx.tenantId}:${ctx.userId}:${x.questionId}`; const w = state.wrong.get(k) || { id: id('wrong'), tenantId: ctx.tenantId, userId: ctx.userId, questionId: x.questionId, questionStem: QUESTION_DATA.find(q => q.id === x.questionId)?.stem, stem: QUESTION_DATA.find(q => q.id === x.questionId)?.stem, errorCount: 0, masterStatus: 'UNMASTERED' }; w.errorCount++; state.wrong.set(k, w); }); return json(res, 200, { ...report, sessionVersion: s.version, status: s.status }); }
|
||||
if (p === '/app-api/education/practice-session/report' && req.method === 'GET') { const s = findSession(ctx, url.searchParams.get('sessionId')); const r = s && state.reports.get(s.id); return r ? json(res, 200, r) : json(res, 404, null, 'report not found'); }
|
||||
if (p === '/app-api/education/practice-session/reports' && req.method === 'GET') return json(res, 200, page([...state.reports].map(([sid, r]) => { const s = state.sessions.get(sid); return own(ctx, s) ? r : null; }).filter(Boolean), url.searchParams));
|
||||
if (p === '/app-api/education/wrong-question/page' && req.method === 'GET') { let list = [...state.wrong.values()].filter(w => own(ctx, w)); if (url.searchParams.get('masterStatus')) list = list.filter(w => w.masterStatus === url.searchParams.get('masterStatus')); return json(res, 200, page(list, url.searchParams)); }
|
||||
if (p === '/app-api/education/wrong-question/get' && req.method === 'GET') { const w = [...state.wrong.values()].find(w => own(ctx, w) && w.id === url.searchParams.get('id')); const q = w && findQuestion(ctx, w.questionId); return w && q ? json(res, 200, { ...w, questionId: q.id, stem: q.stem, answer: q.answer, explanation: q.explanation }) : json(res, 404, null, 'wrong question not found'); }
|
||||
if ((p.endsWith('/master') || p.endsWith('/unmaster')) && req.method === 'PUT') { const b = await body(req); const w = [...state.wrong.values()].find(w => own(ctx, w) && w.id === String(b.id || url.searchParams.get('id'))); if (!w) return json(res, 404, null, 'wrong question not found'); w.masterStatus = p.endsWith('/master') ? 'MASTERED' : 'UNMASTERED'; return json(res, 200, { mastered: w.masterStatus === 'MASTERED', masterStatus: w.masterStatus }); }
|
||||
if (p === '/app-api/education/wrong-question/review-session' && req.method === 'POST') { const b = await body(req); const ids = Array.isArray(b.wrongQuestionIds) ? b.wrongQuestionIds : []; const qs = ids.map(x => [...state.wrong.values()].find(w => own(ctx, w) && w.id === String(x))).filter(Boolean).map(w => findQuestion(ctx, w.questionId)).filter(Boolean); if (!qs.length) return json(res, 400, null, 'no owned wrong questions'); const s = { id: id('session'), tenantId: ctx.tenantId, userId: ctx.userId, clientSessionId: b.clientSessionId || id('client'), collectionId: 'wrong-review', status: 'ACTIVE', version: 0, acceptedSequence: 0, questions: qs.map((q, i) => ({ ...q, questionId: q.id, sequence: i + 1, selectedAnswer: null })) }; state.sessions.set(s.id, s); return json(res, 200, sessionView(s)); }
|
||||
if (p === '/app-api/education/favorite/page' && req.method === 'GET') return json(res, 200, page([...state.favorites.values()].filter(f => own(ctx, f)), url.searchParams));
|
||||
if (p === '/app-api/education/favorite/create' && req.method === 'POST') { const b = await body(req); const q = findQuestion(ctx, b.targetId); if (b.targetType !== 'QUESTION' || !q) return json(res, 404, null, 'question not found'); const key = `${ctx.tenantId}:${ctx.userId}:${q.id}`; const f = state.favorites.get(key) || { id: id('favorite'), tenantId: ctx.tenantId, userId: ctx.userId, targetType: 'QUESTION', targetId: q.id, questionId: q.id, questionStem: q.stem, status: 'ACTIVE' }; f.status = 'ACTIVE'; state.favorites.set(key, f); return json(res, 200, f); }
|
||||
if (p === '/app-api/education/favorite/delete' && req.method === 'DELETE') { const b = await body(req); const f = [...state.favorites.values()].find(f => own(ctx, f) && (b.id && f.id === String(b.id) || b.targetId && f.targetId === String(b.targetId))); if (f) f.status = 'DELETED'; return json(res, 200, { deleted: true }); }
|
||||
if (p === '/app-api/education/favorite/status' && req.method === 'POST') { const b = await body(req); const ids = (b.questionIds || []).filter(qid => findQuestion(ctx, qid)); return json(res, 200, { questionIds: ids.filter(qid => [...state.favorites.values()].some(f => own(ctx, f) && f.status === 'ACTIVE' && f.targetId === String(qid))) }); }
|
||||
return json(res, 404, null, 'Not found');
|
||||
}
|
||||
function serve(res, requestPath) { const file = path.resolve(ROOT, requestPath.slice(1)); if (!file.startsWith(path.resolve(ROOT)) || !fs.existsSync(file) || !fs.statSync(file).isFile()) return json(res, 404, null, 'Not found'); const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json' }; res.writeHead(200, { 'Content-Type': types[path.extname(file)] || 'application/octet-stream' }); fs.createReadStream(file).pipe(res); }
|
||||
function createServer() { return http.createServer((req, res) => { const original = req.headers.authorization; if (original) req.headers.authorization = original; handler(req, res).catch(err => json(res, 400, null, err.message)); }); }
|
||||
if (require.main === module) createServer().listen(PORT, HOST, () => process.stdout.write(`education harness listening on http://${HOST}:${PORT}\n`));
|
||||
module.exports = { createServer, resetState, TOKENS };
|
||||
38
tools/education-student-harness/smoke-route.test.js
Normal file
38
tools/education-student-harness/smoke-route.test.js
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const http = require('http');
|
||||
const { createServer, resetState } = require('./server');
|
||||
|
||||
const port = Number(process.env.SMOKE_PORT || 4188);
|
||||
function request(method, path, token, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||
const req = http.request({ hostname: '127.0.0.1', port, path, method, headers: { Authorization: `Bearer ${token}`, ...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {}) } }, (res) => {
|
||||
let raw = '';
|
||||
res.on('data', (chunk) => { raw += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(raw) }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (payload) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
resetState();
|
||||
const server = createServer().listen(port, '127.0.0.1');
|
||||
try {
|
||||
let response = await request('GET', '/app-api/education/context', 'tenant-a-student-1');
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.body.data.tenantId, 'tenant-a');
|
||||
response = await request('GET', '/app-api/education/questions/page?collectionId=col-a-core', 'tenant-a-student-1');
|
||||
assert.equal(response.body.data.list.length, 3);
|
||||
assert.equal(response.body.data.list[0].answer, undefined);
|
||||
process.stdout.write('education student harness smoke route passed\n');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
main().catch((error) => { process.stderr.write(`${error.stack}\n`); process.exitCode = 1; });
|
||||
44
tools/education-student-harness/styles.css
Normal file
44
tools/education-student-harness/styles.css
Normal file
@@ -0,0 +1,44 @@
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
--ink: #18232b; --muted: #5d696f; --paper: #f5f7f4; --panel: #ffffff; --line: #d9e0dc;
|
||||
--leaf: #245c4d; --deep: #183d3d; --gold: #9a6b20; --wash: #e6efea; --danger: #913d38;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--ink); background: var(--paper); line-height: 1.5;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { margin: 0; min-width: 320px; background: var(--paper); }
|
||||
button, input, select { font: inherit; }
|
||||
button, a { -webkit-tap-highlight-color: transparent; }
|
||||
button { cursor: pointer; }
|
||||
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid #d5a550; outline-offset: 3px; }
|
||||
.topbar { height: 70px; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.82); display: flex; align-items: center; justify-content: space-between; padding: 0 clamp(18px, 5vw, 72px); position: sticky; top: 0; z-index: 3; backdrop-filter: blur(12px); }
|
||||
.wordmark { display: inline-flex; align-items: center; gap: 10px; color: var(--deep); text-decoration: none; font-size: 15px; font-weight: 760; letter-spacing: -.03em; }
|
||||
.wordmark-mark { display: grid; place-items: center; width: 29px; height: 29px; color: white; background: var(--deep); border-radius: 50%; font-size: 21px; line-height: 1; }
|
||||
.topbar-actions { display: flex; align-items: center; gap: 14px; }
|
||||
.identity-chip { padding: 6px 10px; color: var(--leaf); background: var(--wash); border-radius: 99px; font-size: 11px; font-weight: 750; }
|
||||
.quiet-button { border: 0; color: var(--muted); background: transparent; font-size: 12px; padding: 8px; }
|
||||
.page-shell { width: min(1180px, calc(100% - 36px)); margin: 0 auto; padding: 74px 0 80px; }
|
||||
.intro { display: flex; align-items: end; justify-content: space-between; gap: 30px; margin-bottom: 37px; }
|
||||
.kicker, .section-label, .nav-title { margin: 0; color: var(--leaf); font-size: 10px; font-weight: 800; letter-spacing: .17em; text-transform: uppercase; }
|
||||
h1, h2, h3, p { margin-top: 0; } h1 { margin: 13px 0 16px; color: var(--deep); font-family: Georgia, "Times New Roman", serif; font-size: clamp(48px, 7.4vw, 92px); font-weight: 400; letter-spacing: -.07em; line-height: .88; } h1 em { color: var(--gold); font-style: italic; } h2 { margin-bottom: 6px; font-size: 22px; letter-spacing: -.04em; line-height: 1.1; } h3 { margin: 17px 0 8px; font-size: 17px; letter-spacing: -.03em; }
|
||||
.intro-copy { max-width: 395px; margin-bottom: 0; color: var(--muted); font-size: 14px; }
|
||||
.connection-card { display: flex; align-items: center; gap: 9px; align-self: start; min-width: 180px; padding: 11px 13px; border: 1px solid var(--line); background: white; color: var(--leaf); font-size: 12px; font-weight: 700; }
|
||||
.connection-dot { width: 7px; height: 7px; background: var(--gold); border-radius: 50%; } .connection-dot[data-tone="good"] { background: var(--leaf); } .connection-dot[data-tone="bad"] { background: var(--danger); }
|
||||
.request-id { margin-left: auto; color: #a5afb0; font: 10px ui-monospace, monospace; font-weight: 400; }
|
||||
.safety-note { display: flex; gap: 12px; align-items: start; margin-bottom: 32px; padding: 13px 16px; border-left: 2px solid var(--gold); background: #fbf7ee; color: #755f43; font-size: 12px; } .safety-note > span:first-child { color: var(--gold); font-size: 17px; line-height: 1; } code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; }
|
||||
.auth-panel, .content-section { border-top: 1px solid var(--line); padding-top: 18px; } .auth-panel { display: grid; grid-template-columns: 95px 1fr; gap: 35px; padding-bottom: 38px; }
|
||||
.section-label { display: flex; gap: 11px; color: #8c9896; } .section-label span:first-child { color: var(--gold); }
|
||||
.auth-main { display: grid; grid-template-columns: 1fr minmax(300px, 410px); gap: 32px; } .auth-main p, .section-heading p { color: var(--muted); font-size: 13px; margin-bottom: 0; } form label { display: block; margin-bottom: 7px; color: var(--ink); font-size: 12px; font-weight: 700; } form label span { color: var(--muted); font-weight: 400; }
|
||||
.login-fields { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; } .token-label { margin-top: 14px; } .token-label + input { max-width: 280px; }
|
||||
.input-action { display: flex; gap: 8px; } input, select { width: 100%; min-height: 42px; border: 1px solid var(--line); border-radius: 2px; color: var(--ink); background: #fbfcfb; padding: 9px 11px; } .field-help { color: var(--muted); font-size: 11px !important; margin-top: 7px !important; }
|
||||
.button { min-height: 39px; padding: 8px 14px; border: 1px solid var(--line); border-radius: 2px; font-size: 12px; font-weight: 750; white-space: nowrap; transition: transform .15s ease, background .15s ease, border-color .15s ease; } .button:hover { transform: translateY(-1px); } .button-dark { border-color: var(--deep); color: white; background: var(--deep); } .button-outline { color: var(--leaf); background: white; } .button-small { min-height: 31px; padding: 5px 9px; font-size: 11px; }
|
||||
.identity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; grid-column: 2; margin: 26px 0 0; } .identity-grid div { padding: 11px 13px; background: var(--wash); } dt { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .1em; } dd { margin: 2px 0 0; font-weight: 700; font-size: 13px; }
|
||||
.workspace { display: grid; grid-template-columns: 160px 1fr; gap: 52px; } .side-nav { border-top: 1px solid var(--line); padding-top: 18px; } .nav-title { margin-bottom: 22px; color: #8c9896; } .nav-link { display: flex; gap: 11px; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--line); color: var(--muted); text-decoration: none; font-size: 12px; } .nav-link span { color: var(--gold); font: 10px ui-monospace, monospace; } .nav-link.active { color: var(--deep); font-weight: 750; } .nav-foot { margin-top: 45px; color: #8d9997; font-size: 10px; line-height: 1.6; } .nav-foot span { color: var(--leaf); }
|
||||
.content-column { min-width: 0; } .content-section { margin-bottom: 52px; } .section-heading { display: flex; justify-content: space-between; align-items: end; gap: 20px; margin: 18px 0 20px; } .filters { display: flex; gap: 9px; max-width: 460px; margin-bottom: 20px; } .filters label { flex: 1; color: var(--muted); font-size: 11px; } .filters select { display: block; margin-top: 5px; }
|
||||
.collection-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } .collection-card { min-height: 225px; display: flex; flex-direction: column; padding: 19px; border: 1px solid var(--line); background: white; } .collection-index { color: var(--gold); font: 10px ui-monospace, monospace; letter-spacing: .15em; } .collection-card p { min-height: 42px; color: var(--muted); font-size: 12px; } .collection-meta { display: flex; justify-content: space-between; margin: auto 0 16px; color: var(--muted); font: 10px ui-monospace, monospace; text-transform: uppercase; }
|
||||
.practice-section { scroll-margin-top: 90px; } .practice-card { border: 1px solid var(--line); background: white; } .empty-state { padding: 25px 0; margin: 0; color: var(--muted); font-size: 13px; } .practice-card > .empty-state, .item-list > .empty-state { padding: 25px; } .practice-head { display: flex; justify-content: space-between; align-items: center; padding: 15px 18px; border-bottom: 1px solid var(--line); } .practice-head strong { margin-left: 10px; font-size: 12px; } .session-badge { color: var(--leaf); font: 10px ui-monospace, monospace; letter-spacing: .1em; } .save-state { color: var(--muted); font-size: 11px; } .save-state[data-state="saved"] { color: var(--leaf); } .save-state[data-state="retrying"] { color: var(--gold); } .save-state[data-state="failed"] { color: var(--danger); }
|
||||
.question { border: 0; border-bottom: 1px solid var(--line); margin: 0; padding: 22px 22px 20px; } .question legend { display: flex; gap: 12px; width: 100%; margin-bottom: 15px; font-size: 14px; font-weight: 700; } .question legend span { color: var(--gold); font: 11px ui-monospace, monospace; } .options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } .option { position: relative; } .option input { position: absolute; opacity: 0; } .option span { display: block; min-height: 44px; padding: 11px 12px; border: 1px solid var(--line); color: var(--muted); font-size: 12px; cursor: pointer; } .option b { margin-right: 8px; color: var(--gold); font: 11px ui-monospace, monospace; } .option input:checked + span { border-color: var(--leaf); color: var(--deep); background: var(--wash); } .practice-actions { display: flex; justify-content: flex-end; padding: 18px 22px; }
|
||||
.result-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; } .result-panel { min-width: 0; border-top: 1px solid var(--line); padding-top: 18px; } .item-list { border: 1px solid var(--line); background: white; } .list-item { display: flex; justify-content: space-between; align-items: center; gap: 14px; padding: 13px 15px; border-bottom: 1px solid var(--line); } .list-item:last-child { border-bottom: 0; } .list-item strong, .list-item span { display: block; } .list-item strong { font-size: 12px; } .list-item span { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
||||
footer { display: flex; gap: 19px; width: min(1180px, calc(100% - 36px)); margin: 0 auto; padding: 20px 0 30px; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; } footer a { color: var(--leaf); }
|
||||
@media (max-width: 820px) { .page-shell { padding-top: 48px; } .intro { display: block; } .connection-card { width: fit-content; margin-top: 24px; } .auth-panel { grid-template-columns: 1fr; gap: 18px; } .auth-main { grid-template-columns: 1fr; gap: 22px; } .identity-grid { grid-column: 1; margin-top: 0; } .workspace { grid-template-columns: 1fr; gap: 25px; } .side-nav { display: flex; gap: 14px; align-items: center; overflow-x: auto; } .nav-title, .nav-foot { display: none; } .nav-link { border-bottom: 0; white-space: nowrap; } .collection-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 560px) { .topbar { height: 62px; padding: 0 17px; } .identity-chip { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .page-shell { width: min(100% - 28px, 500px); padding-top: 38px; } h1 { font-size: 57px; } .auth-main, .section-heading { display: block; } .section-heading .button { margin-top: 16px; } .login-fields { grid-template-columns: 1fr; } .input-action { display: grid; grid-template-columns: 1fr; } .filters, .options, .collection-grid, .result-grid { grid-template-columns: 1fr; display: grid; max-width: none; } .collection-card { min-height: 0; } .question { padding: 19px 15px; } footer { width: min(100% - 28px, 500px); flex-wrap: wrap; } }
|
||||
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } *, *::before, *::after { transition-duration: .01ms !important; } }
|
||||
30
tools/education-student-harness/test.js
Normal file
30
tools/education-student-harness/test.js
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
const assert = require('assert');
|
||||
const http = require('http');
|
||||
const { createServer, resetState } = require('./server');
|
||||
const port = 4187;
|
||||
let server;
|
||||
function request(method, path, token, body, headers = {}) { return new Promise((resolve, reject) => { const data = body === undefined ? undefined : JSON.stringify(body); const req = http.request({ hostname: '127.0.0.1', port, path, method, headers: { Authorization: `Bearer ${token}`, ...(data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}), ...headers } }, res => { let raw = ''; res.on('data', c => raw += c); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(raw) })); }); req.on('error', reject); if (data) req.write(data); req.end(); }); }
|
||||
async function run() {
|
||||
resetState(); server = createServer().listen(port, '127.0.0.1');
|
||||
const a1 = 'tenant-a-student-1'; const a2 = 'tenant-a-student-2'; const b1 = 'tenant-b-student-1';
|
||||
let r = await request('GET', '/app-api/education/context', a1); assert.equal(r.body.data.tenantId, 'tenant-a');
|
||||
r = await request('GET', '/app-api/education/questions/page?collectionId=col-a-core', a1); assert.equal(r.body.data.list[0].answer, undefined); assert.equal(r.body.data.list.length, 3);
|
||||
r = await request('POST', '/app-api/education/practice-session/create', a1, { clientSessionId: 'client-1', collectionId: 'col-a-core', questionCount: 3 }); const s = r.body.data;
|
||||
r = await request('POST', '/app-api/education/practice-session/create', a1, { clientSessionId: 'client-1', collectionId: 'col-a-core', questionCount: 3 }); assert.equal(r.body.data.id, s.id);
|
||||
r = await request('PUT', '/app-api/education/practice-session/answer', a1, { sessionId: s.id, questionSequence: 1, selectedAnswer: 'A', idempotencyKey: 'ans-1', clientSequence: 1, expectedSessionVersion: 0 }, { 'X-Harness-Fault': 'answer-timeout-after-commit' }); assert.equal(r.status, 504);
|
||||
r = await request('PUT', '/app-api/education/practice-session/answer', a1, { sessionId: s.id, questionSequence: 1, selectedAnswer: 'A', idempotencyKey: 'ans-1', clientSequence: 1, expectedSessionVersion: 0 }); assert.equal(r.status, 200); assert.equal(r.body.data.selectedAnswer, 'A');
|
||||
r = await request('GET', `/app-api/education/practice-session/get?id=${s.id}`, a1); assert.equal(r.body.data.questions[0].selectedAnswer, 'A');
|
||||
r = await request('PUT', '/app-api/education/practice-session/answer', a1, { sessionId: s.id, questionSequence: 2, selectedAnswer: 'B', idempotencyKey: 'ans-2', clientSequence: 2, expectedSessionVersion: 0 }); assert.equal(r.status, 409);
|
||||
r = await request('PUT', '/app-api/education/practice-session/answer', a1, { sessionId: s.id, questionSequence: 2, selectedAnswer: 'B', idempotencyKey: 'ans-2', clientSequence: 2, expectedSessionVersion: 1 }); assert.equal(r.status, 200);
|
||||
r = await request('GET', `/app-api/education/practice-session/get?id=${s.id}`, a2); assert.equal(r.status, 404);
|
||||
r = await request('POST', '/app-api/education/favorite/create', a1, { targetType: 'QUESTION', targetId: 'q-a-1' }); assert.equal(r.status, 200);
|
||||
r = await request('GET', '/app-api/education/favorite/page', b1); assert.equal(r.body.data.total, 0);
|
||||
r = await request('POST', '/app-api/education/practice-session/submit', a1, { sessionId: s.id, idempotencyKey: 'submit-1', expectedSessionVersion: 2 }); assert.equal(r.status, 200); assert.equal(r.body.data.details[0].answer, 'A');
|
||||
r = await request('POST', '/app-api/education/practice-session/submit', a1, { sessionId: s.id, idempotencyKey: 'submit-1', expectedSessionVersion: 2 }); assert.equal(r.status, 200);
|
||||
r = await request('PUT', '/app-api/education/practice-session/answer', a1, { sessionId: s.id, questionSequence: 1, selectedAnswer: 'C', idempotencyKey: 'ans-3', clientSequence: 3, expectedSessionVersion: 3 }); assert.equal(r.status, 409);
|
||||
r = await request('GET', '/app-api/education/wrong-question/page', a1); assert.equal(r.body.data.total, 2);
|
||||
server.close(); process.stdout.write('education student harness contract tests passed\n');
|
||||
}
|
||||
run().catch(err => { if (server) server.close(); console.error(err); process.exitCode = 1; });
|
||||
Reference in New Issue
Block a user