/* 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 = '
' + 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: '' }); } 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 = '
    ' + course + ' 已答0/40
    ' + (options.singleAttempt ? '

    剩余考试次数:1次

    ' : '') + '
    开始答题
    '; document.querySelector('.examdetail_button').onclick = () => { qaEnter++; history.pushState({}, '', '/clientcn/exam/lease-recovery/questions'); document.body.innerHTML = '
    ' + course + '
    '; }; }; 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; });