Files
yianxue-assistant/tests/lease-recovery.cjs
T

296 lines
17 KiB
JavaScript

/* Isolated scheduler lease regression: all business routes are local fixtures. */
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const workspace = path.resolve(__dirname, '..');
const sources = ['question-bank-core.js', 'attempt-policy.js', 'learning-engine.js']
.map(file => fs.readFileSync(path.join(workspace, file), 'utf8'));
const origin = 'http://10.119.67.7/clientcn/';
const course = '租约恢复测试课程';
const videoHTML = '<app-course><div data-course-title>' + course + '</div><li class="cn_courseware_img active" data-courseware-id="lease-video" data-required-seconds="100"><p>测试视频</p><div class="cn_courseware_img_icon">视频</div><div class="menu_courseware_left">已学习20秒</div><div class="courseware_finished_status"><span class="en_courseware_finished en_courseware_noFinished">尚未完成</span></div><p class="related_test">' + course + '</p></li><app-course-courseware-video><video id="lease-player"></video></app-course-courseware-video></app-course>';
(async () => {
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
let checks = 0;
const allErrors = [], allUnexpected = [];
try {
async function fixture(options = {}) {
const memory = new Map();
const context = await browser.newContext();
await context.exposeBinding('__leaseRead', (_, key) => memory.has(key) ? structuredClone(memory.get(key)) : null);
await context.exposeBinding('__leaseWrite', (_, key, value) => memory.set(key, structuredClone(value)));
await context.route('**/*', route => {
if (route.request().isNavigationRequest() && route.request().url().startsWith(origin)) {
return route.fulfill({ status: 200, contentType: 'text/html;charset=utf-8', body: '<!doctype html><meta charset="utf-8"><body></body>' });
}
allUnexpected.push(route.request().url()); return route.abort();
});
const page = await context.newPage();
page.on('pageerror', error => allErrors.push(error.message));
await page.goto(origin + 'course/lease-recovery');
for (const content of sources) await page.addScriptTag({ content });
await page.evaluate(async ({ videoHTML, course, options }) => {
document.body.innerHTML = videoHTML;
window.qaClock = 100000;
window.qaRelated = 0; window.qaEnter = 0; window.qaExamStarts = 0; window.qaExamPauses = 0; window.qaExamActive = false; window.qaPlay = 0;
window.qaExamState = { stage: 'answering', total: 40, verifiedCount: 0 };
window.qaVideo = { currentTime: 20, duration: 100, ended: false, paused: true, playbackRate: 1 };
const video = document.querySelector('video');
for (const key of Object.keys(qaVideo)) Object.defineProperty(video, key, { configurable: true, get: () => qaVideo[key], set: value => { qaVideo[key] = value; } });
video.play = async () => { qaPlay++; qaVideo.paused = false; };
video.pause = () => { qaVideo.paused = true; };
window.qaSnapshot = { accountId: 'lease-account', bankVersion: 1, planId: 'lease-plan', courses: [course] };
window.qaStorage = { get: key => __leaseRead(key), set: (key, value) => __leaseWrite(key, value) };
document.querySelector('.related_test').onclick = () => {
qaRelated++;
history.pushState({}, '', '/clientcn/exam/lease-recovery');
document.body.innerHTML = '<app-examdetail><div class="examdetail_header">' + course + ' 已答0/40</div>' + (options.singleAttempt ? '<p>剩余考试次数:1次</p>' : '') + '<div class="examdetail_button">开始答题</div></app-examdetail>';
document.querySelector('.examdetail_button').onclick = () => {
qaEnter++;
history.pushState({}, '', '/clientcn/exam/lease-recovery/questions');
document.body.innerHTML = '<app-examtest><div class="pc_examtest_title">' + course + '</div></app-examtest>';
};
};
window.qaCreate = () => YianLearningEngine.create({
ownerId: 'local-owner', now: () => qaClock, storage: qaStorage,
getSnapshot: () => Object.assign({ document, url: location.href }, qaSnapshot),
getExamState: () => qaExamState,
onExamStart: async () => { qaExamStarts++; qaExamActive = true; return true; },
onExamPause: () => { qaExamPauses++; qaExamActive = false; }, navigate: async () => { throw new Error('fixture only uses the actual associated exam element'); }
});
window.qaEngine = qaCreate();
await qaEngine.configure({ mode: 'all', execution: 'sequential' });
await qaEngine.start();
}, { videoHTML, course, options });
const tick = delta => page.evaluate(async delta => { qaClock += delta || 0; return qaEngine.tick(); }, delta);
const read = () => page.evaluate(() => ({ state: qaEngine.getState(), related: qaRelated, entered: qaEnter, starts: qaExamStarts, examPauses: qaExamPauses, examActive: qaExamActive, plays: qaPlay, videoPaused: qaVideo.paused }));
async function blockExam() {
const state = await page.evaluate(async () => {
const control = await qaStorage.get('control.v1');
const task = qaEngine.getState().tasks.find(item => item.kind === 'exam');
await qaStorage.set('lease:exam', { owner: 'other-page-owner', sessionId: control.sessionId, taskId: task.id, expires: qaClock + 18000 });
document.querySelector('.cn_courseware_img').setAttribute('data-is-learned', 'true');
await qaEngine.tick();
return qaEngine.tick();
});
const current = await read();
assert.equal(state.status, 'running'); assert.equal(current.related, 0); assert.equal(current.entered, 0); assert.equal(current.starts, 0);
assert.match(state.message + ' ' + state.tasks.find(item => item.kind === 'exam').reason, /租约|执行权|另一(?:个)?页面/);
}
async function runUntilExam() {
for (let i = 0; i < 5; i++) await tick(0);
const current = await read();
assert.equal(current.related, 1, '租约失效后自动点击原关联考试入口');
assert.equal(current.entered, 1, '不需要用户再次点击继续即可进入本场试卷');
assert.equal(current.starts, 1, '仅启动一次当前考试控制器');
assert.doesNotMatch(current.state.message, /等待同类任务租约释放|等待(?:视频|考试)执行权/, '领取成功后不得残留等待执行权提示');
return current;
}
return { page, context, memory, tick, read, blockExam, runUntilExam, close: () => context.close() };
}
// Upgrade the exact persisted 2.1.2 wait reason. A controlled reload keeps
// this running session while the old waiting task becomes retryable.
{
const f = await fixture();
try {
await f.blockExam();
const restored = await f.page.evaluate(async () => {
qaEngine.destroy(); await new Promise(resolve => setTimeout(resolve, 0));
const saved = await qaStorage.get('session.v1');
const exam = saved.tasks.find(task => task.kind === 'exam');
exam.status = 'waiting'; exam.reason = '另一个页面正在处理同类任务,避免重复领取';
delete exam.leaseWait;
saved.activeTaskId = ''; saved.status = 'running';
await qaStorage.set('session.v1', saved);
await qaStorage.set('recover:' + location.href, { sessionId: saved.id, taskId: '', accountId: 'lease-account', expires: qaClock + 60000 });
qaEngine = qaCreate(); return qaEngine.tick();
});
assert.equal(restored.status, 'running'); assert.equal((await f.read()).related, 0);
await f.tick(18001); await f.runUntilExam(); checks++;
} finally { await f.close(); }
}
// The completed video leaves a pending exam whose previous executor closed.
// An unexpired lease waits, then the very same running session retries itself.
{
const f = await fixture();
try {
await f.blockExam(); await f.tick(17999);
assert.equal((await f.read()).related, 0, '有效期内不能重复打开考试');
await f.tick(2); await f.runUntilExam(); checks++;
} finally { await f.close(); }
}
// A real second browser page renews its lease. The coordinator must leave
// that page alone for arbitrarily many lease lifetimes, then recover once it
// stops heartbeating. Its writes use the same shared storage binding.
{
const f = await fixture();
try {
await f.blockExam();
const other = await f.context.newPage();
other.on('pageerror', error => allErrors.push(error.message));
await other.goto(origin + 'exam/other-lease-holder');
for (let i = 0; i < 4; i++) {
await other.evaluate(async clock => {
const held = await __leaseRead('lease:exam');
if (!held || held.owner !== 'other-page-owner') throw new Error('另一个真实页面的租约被抢占');
held.expires = clock + 18000; await __leaseWrite('lease:exam', held);
}, 100000 + (i + 1) * 15000);
await f.tick(15000);
const current = await f.read();
assert.equal(current.related, 0); assert.equal(current.entered, 0); assert.equal(current.starts, 0);
assert.equal(f.memory.get('lease:exam').owner, 'other-page-owner');
}
await other.close(); await f.tick(18001); await f.runUntilExam(); checks++;
} finally { await f.close(); }
}
// An active task can lose a lease without entering the async-quiz path.
// Restoring its own lease lets it continue and must clear the old banner.
{
const f = await fixture();
try {
await f.page.evaluate(async () => {
const held = await qaStorage.get('lease:video');
held.owner = 'other-page-owner'; held.expires = qaClock + 18000; await qaStorage.set('lease:video', held);
});
const blocked = await f.tick(1000);
assert.match(blocked.message, /租约|执行权/);
const before = await f.read();
assert.equal(before.videoPaused, true, '丢失执行权后立即暂停本页旧视频');
await f.page.evaluate(async () => {
const held = await qaStorage.get('lease:video'); held.owner = 'local-owner'; held.expires = qaClock - 1;
await qaStorage.set('lease:video', held);
qaVideo.currentTime += 1;
});
const recovered = await f.tick(1000);
assert.equal(recovered.status, 'running');
assert.equal(f.memory.get('lease:video').owner, 'local-owner');
assert.ok(f.memory.get('lease:video').expires > 102000);
assert.doesNotMatch(recovered.message, /等待同类任务租约释放|等待(?:视频|考试)执行权/);
const after = await f.read();
assert.equal(after.starts, before.starts);
assert.equal(after.videoPaused, false, '重新取得执行权后正常恢复原视频');
assert.equal(after.plays, before.plays + 1, '恢复时重启本页播放器一次'); checks++;
} finally { await f.close(); }
}
// Losing an already-started exam lease invalidates its old answer/submit
// controller immediately. Waiting is automatic, while reacquisition must
// call the normal exam entry callback again to reverify the current paper.
{
const f = await fixture();
try {
await f.blockExam(); await f.tick(18001); await f.runUntilExam();
const initial = await f.read();
assert.equal(initial.examActive, true);
await f.page.evaluate(async () => {
const held = await qaStorage.get('lease:exam');
held.owner = 'other-exam-page'; held.expires = qaClock + 18000;
await qaStorage.set('lease:exam', held);
});
const blocked = await f.tick(1);
let current = await f.read();
assert.equal(blocked.status, 'running', '执行权等待不需要重新人工启动全部任务');
assert.equal(current.state.tasks.find(task => task.kind === 'exam').status, 'waitingLease');
assert.equal(current.examPauses, initial.examPauses + 1, '首次丢失考试执行权立即停止旧答题控制器');
assert.equal(current.examActive, false); assert.equal(current.starts, 1);
assert.equal(f.memory.get('lease:exam').owner, 'other-exam-page', '不得抢占有效的其他页面考试执行权');
await f.tick(17998); current = await f.read();
assert.equal(current.examPauses, initial.examPauses + 1, '等待轮询不重复暂停或开启旧控制器');
assert.equal(current.examActive, false); assert.equal(current.starts, 1);
assert.equal(f.memory.get('lease:exam').owner, 'other-exam-page');
await f.tick(2); current = await f.read();
assert.equal(current.starts, 2, '过期重新领取后必须重新核验当前试卷');
assert.equal(current.examActive, true);
assert.equal(f.memory.get('lease:exam').owner, 'local-owner');
assert.equal(current.entered, 1, '重验已有试卷不得再次点击开始答题');
await f.tick(0); assert.equal((await f.read()).starts, 2, '核验通过后不重复重启考试控制器');
checks++;
} finally { await f.close(); }
}
// Reacquiring a manual-confirmation exam must keep its read-only waiting
// state instead of silently restarting the automatic answer controller.
{
const f = await fixture();
try {
await f.blockExam(); await f.tick(18001); await f.runUntilExam();
await f.page.evaluate(() => {
qaExamState = { stage: 'waiting', total: 40, verifiedCount: 32, predictedScore: 80, requiresManualSubmission: true, message: '本场等待用户手动确认交卷' };
qaExamActive = false;
});
await f.tick(0);
const initial = await f.read();
assert.equal(initial.state.tasks.find(task => task.kind === 'exam').status, 'waiting');
await f.page.evaluate(async () => {
const held = await qaStorage.get('lease:exam'); held.owner = 'other-exam-page'; held.expires = qaClock + 18000;
await qaStorage.set('lease:exam', held);
});
await f.tick(1); await f.tick(18000); await f.tick(0);
const current = await f.read();
assert.equal(current.state.tasks.find(task => task.kind === 'exam').status, 'waiting', '人工确认等待不能因执行权恢复而转回自动作答');
assert.equal(current.starts, 1); assert.equal(current.examActive, false);
assert.equal(current.examPauses, initial.examPauses, '已处于人工等待时保留原考试控制器的只读结果同步');
checks++;
} finally { await f.close(); }
}
// Lease expiry is never an authorization to ignore user pause or identity.
for (const protection of ['globalPause', 'examPause', 'bankVersion', 'account']) {
const f = await fixture();
try {
await f.blockExam();
await f.page.evaluate(async protection => {
if (protection === 'globalPause') await qaEngine.pause();
else if (protection === 'examPause') await qaEngine.configure({ examPaused: true });
else if (protection === 'bankVersion') qaSnapshot.bankVersion = 2;
else qaSnapshot.accountId = 'different-account';
}, protection);
await f.tick(18001); for (let i = 0; i < 4; i++) await f.tick(0);
const current = await f.read();
assert.equal(current.entered, 0, protection + '不得自动开始考试');
assert.equal(current.starts, 0, protection + '不得启动答题控制器');
if (protection !== 'examPause') assert.equal(current.state.status, 'paused');
checks++;
} finally { await f.close(); }
}
// Topic preflight is rerun after waiting, so a no-longer-associated bank
// cannot inherit a previously matched topic merely because the lease freed.
{
const f = await fixture();
try {
await f.blockExam();
await f.page.evaluate(() => { qaSnapshot.courses = ['其他课程']; });
await f.tick(18001); for (let i = 0; i < 4; i++) await f.tick(0);
const current = await f.read();
assert.equal(current.related, 0); assert.equal(current.entered, 0); assert.equal(current.starts, 0);
const exam = current.state.tasks.find(task => task.kind === 'exam');
assert.match(exam.reason, /题库|主题/); checks++;
} finally { await f.close(); }
}
// The normal one-attempt rule still runs on the detail page after a lease
// retry opens its associated exam, with no early entry or controller start.
{
const f = await fixture({ singleAttempt: true });
try {
await f.blockExam(); await f.tick(18001);
for (let i = 0; i < 5; i++) await f.tick(0);
const current = await f.read();
assert.equal(current.related, 1); assert.equal(current.entered, 0); assert.equal(current.starts, 0);
assert.equal(current.state.exam.requiresManualSubmission, true);
assert.match(current.state.tasks.find(task => task.kind === 'exam').reason, /1 次|1次/); checks++;
} finally { await f.close(); }
}
assert.deepEqual(allErrors, []); assert.deepEqual(allUnexpected, []);
console.log('lease-recovery:', checks, 'checks passed (自动接续、真实第二页续租、旧提示清理、暂停/身份/主题/单次机会保护;零真实业务请求)');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });