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> @
137 lines
6.8 KiB
JavaScript
137 lines
6.8 KiB
JavaScript
'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}`);
|
||
});
|
||
});
|