Files
Learn_System/backend/tests/quantik-authoring.test.js
Maxim Dolgolyov c780b6fd96 @
feat(quantik-game): фаза 5 — авторинг игровых уровней в sim-builder + раздача

Учитель собирает игровой уровень без кода: новая (аддитивная, сворачиваемая)
панель в sim-builder задаёт блок goal (when/title/hint/hold/fail) + до 3
звёзд + game-мету (chapter/order/par_ms); выражения проверяются inline через
SimExpr.compile (без eval). buildSpec/loadFromSim — round-trip без потерь
(goal/game пишутся только при включённом слое; обычная sim не меняется).
Кнопка «Играть» монтирует черновик в SimEngine-модалке (HUD цели из Ф0).
QuantikLevels стал async: подмешивает custom_sims cat=game (свои+
published) в реестр (custom:<dbid>), offline-safe, строки без goal
отбрасываются; deep-link /quantik?level=custom:<id> с серверной проверкой
доступа (own|published → иначе 403/404), мимо геймплейного гейта unlockStars.
Раздача классу — реюз share Ф6 (game-aware ссылка + durable pushNotif).
Правки sim-builder строго аддитивны (параллельная сессия). npm test 259/8
baseline; quantik-authoring 6/6; lint:routes 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-14 16:09:10 +03:00

137 lines
6.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* Integration tests: Квантик Фаза 5 — авторинг/раздача игровых уровней.
* Уровень = custom_sims с cat='game' + блок goal/game в спеке. Покрываем:
* - создание игрового уровня (goal+game принимаются validateSpec'ом);
* - доступ: чужой DRAFT игровой уровень → 403 (deep-link/embed не утечёт),
* свой draft / чужой published → виден;
* - раздача классу игрового уровня шлёт ДОЛГОВЕЧНОЕ уведомление со ссылкой
* /quantik?level=custom:<id> (тип game_level_shared), авто-публикация.
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, db, inject, getToken, cleanup } = require('./setup');
// Mount /api/custom-sims on the shared test app (setup.js его не монтирует).
app.use('/api/custom-sims', require('../src/routes/customSims'));
after(() => cleanup());
/* Минимальная валидная спека ИГРОВОГО уровня: goal + game-метаданные. */
const GAME_SPEC = {
specVersion: 1,
meta: { title: 'Мой уровень' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 8 },
params: [{ name: 'theta', label: 'Угол', min: 10, max: 80, step: 1, value: 45 }],
physics: { enabled: true, gravity: { x: 0, y: -9.8 } },
objects: [
{ id: 'ball', type: 'point', x: 0, y: 0, r: 7, body: { mass: 1, vx: 'cos(theta*pi/180)*10', vy: 'sin(theta*pi/180)*10' } },
{ type: 'circle', id: 'gate', x: 8, y: 1, r: 0.8, color: '#A78BFA' },
],
goal: {
title: 'Попади в портал',
hint: 'Подбери угол',
when: 'hypot(ball.x - 8, ball.y - 1) < 0.8',
fail: 'ball.y < -1 || t > 8',
stars: [{ when: 't*1000 <= 1500', label: 'Быстро' }],
},
game: { chapter: 'custom', order: 3, par_ms: 1500 },
};
function seedClass(teacherId, studentIds) {
const code = 'C' + Math.random().toString(36).slice(2, 10).toUpperCase();
const r = db.prepare(
'INSERT INTO classes (name, teacher_id, invite_code) VALUES (?, ?, ?)'
).run('Класс ' + code, teacherId, code);
const classId = Number(r.lastInsertRowid);
const ins = db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)');
for (const uid of studentIds) ins.run(classId, uid);
return classId;
}
async function createGameLevel(token, overrides) {
return inject('POST', '/api/custom-sims',
Object.assign({ title: 'Мой уровень', cat: 'game', spec: GAME_SPEC }, overrides || {}), token);
}
describe('Квантик Ф5 — авторинг игровых уровней', () => {
let teacher, otherTeacher, student, studentB, admin;
before(async () => {
teacher = await getToken('teacher');
otherTeacher = await getToken('teacher');
student = await getToken('student');
studentB = await getToken('student');
admin = await getToken('admin');
});
it('teacher creates a GAME level (cat=game, goal+game preserved)', async () => {
const res = await createGameLevel(teacher.token);
assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`);
const get = await inject('GET', `/api/custom-sims/${res.body.id}`, null, teacher.token);
const s = get.body.sim;
assert.equal(s.cat, 'game', 'cat=game accepted');
assert.ok(s.spec.goal && s.spec.goal.when, 'goal.when preserved');
assert.equal(s.spec.goal.stars.length, 1, 'star preserved');
assert.ok(s.spec.game && s.spec.game.chapter === 'custom', 'game.chapter preserved');
assert.equal(s.spec.game.order, 3, 'game.order preserved');
assert.equal(s.spec.game.par_ms, 1500, 'game.par_ms preserved');
});
it("another user's DRAFT game level → 403 (deep-link / ensureSpec cannot leak)", async () => {
const c = await createGameLevel(teacher.token); // draft
const get = await inject('GET', `/api/custom-sims/${c.body.id}`, null, otherTeacher.token);
assert.equal(get.status, 403, `got ${get.status}`);
// student also cannot open another user's draft level
const getS = await inject('GET', `/api/custom-sims/${c.body.id}`, null, student.token);
assert.equal(getS.status, 403, `student got ${getS.status}`);
});
it('published game level is visible to any user (deep-link works)', async () => {
const c = await createGameLevel(teacher.token, { status: 'published' });
assert.equal(c.status, 201);
const get = await inject('GET', `/api/custom-sims/${c.body.id}`, null, student.token);
assert.equal(get.status, 200, 'student can read published game level');
assert.ok(get.body.sim.spec.goal, 'goal present');
// и присутствует в общем списке для другого учителя
const list = await inject('GET', '/api/custom-sims', null, otherTeacher.token);
assert.ok(list.body.sims.find(s => s.id === c.body.id && s.cat === 'game'), 'published game in list');
});
it('owner sees own draft game level (for editing)', async () => {
const c = await createGameLevel(teacher.token);
const get = await inject('GET', `/api/custom-sims/${c.body.id}`, null, teacher.token);
assert.equal(get.status, 200, 'owner reads own draft');
});
it('share game level → game_level_shared notification with /quantik link + auto-publish', async () => {
const classId = seedClass(teacher.userId, [student.userId, studentB.userId]);
const c = await createGameLevel(teacher.token); // draft
const simId = c.body.id;
const res = await inject('POST', `/api/custom-sims/${simId}/share`, { classId }, teacher.token);
assert.equal(res.status, 200, `got ${res.status}: ${JSON.stringify(res.body)}`);
assert.equal(res.body.sent, 2, 'two students notified');
assert.equal(res.body.status, 'published', 'auto-published');
assert.equal(res.body.link, '/quantik?level=custom:' + simId, 'reports game link');
const after = db.prepare('SELECT status FROM custom_sims WHERE id = ?').get(simId);
assert.equal(after.status, 'published', 'sim auto-published in DB');
const notif = db.prepare(
"SELECT type, link FROM notifications WHERE user_id = ? AND type = 'game_level_shared' ORDER BY id DESC"
).get(student.userId);
assert.ok(notif, 'student has game_level_shared notification');
assert.equal(notif.link, '/quantik?level=custom:' + simId, 'notification links to /quantik');
});
it('rejects game level with too many stars (>3) (400)', async () => {
const bad = {
...GAME_SPEC,
goal: { ...GAME_SPEC.goal, stars: [{ when: 'a' }, { when: 'b' }, { when: 'c' }, { when: 'd' }] },
};
const res = await createGameLevel(teacher.token, { spec: bad });
assert.equal(res.status, 400, `got ${res.status}`);
});
});