Files
Haocode/tests/test_math_extract.js
T
2026-09-17 16:30:02 +08:00

124 lines
6.9 KiB
JavaScript

// -*- coding: utf-8 -*-
// 公式管线单测:从 app.js 中切出【真实】公式段 + computeSafeLen,在沙箱里执行
// 运行: node tests/test_math_extract.js
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const src = fs.readFileSync(path.join(__dirname, '..', 'ui', 'web', 'app.js'), 'utf8');
// ---- 切出公式段(公式渲染 section 起点 → 全局事件委托 section 之前)----
const secStart = src.indexOf('// ==================== 🌟 公式渲染');
const secEnd = src.indexOf('// ==================== 全局事件委托');
if (secStart < 0 || secEnd < 0 || secEnd <= secStart) {
console.error('FAIL 无法切出公式段 (start=%d end=%d)', secStart, secEnd);
process.exit(1);
}
let mathCode = src.slice(secStart, secEnd);
// ---- 切出 computeSafeLen 函数(行首锚定,避开文档注释里的同名行)----
var _m = src.match(/^function computeSafeLen\(s, stableLen\) \{$/m);
if (!_m) { console.error('FAIL 无法切出 computeSafeLen'); process.exit(1); }
var csStart = _m.index; // 注意:indexOf 会命中文档注释里的同名行,必须用正则的 index
const csEnd = src.indexOf('// 初始化/获取某个容器 div 的增量渲染状态');
if (csStart < 0 || csEnd < 0) { console.error('FAIL 无法切出 computeSafeLen'); process.exit(1); }
mathCode += '\n' + src.slice(csStart, csEnd);
// ---- 沙箱 ----
const sandbox = {
console,
escapeHtml: (t) => String(t).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'),
katex: { renderToString: (tex, opts) => '<KATEX d=' + (opts.displayMode ? 1 : 0) + '>' + tex + '</KATEX>' },
marked: { parse: (t) => '<MD>' + String(t), setOptions() {}, bind: null },
};
sandbox.marked.parse.bind = function () { return sandbox.marked.parse; };
vm.createContext(sandbox);
vm.runInContext(mathCode, sandbox, { filename: 'app.js-math-section' });
const extractMath = sandbox.extractMath;
const restoreMath = sandbox.restoreMath;
const findUnclosedMathFrom = sandbox.findUnclosedMathFrom;
const computeSafeLen = sandbox.computeSafeLen;
let pass = 0, fail = 0;
function check(name, cond, extra) {
if (cond) { pass++; console.log('PASS ' + name); }
else { fail++; console.log('FAIL ' + name + (extra !== undefined ? ' | ' + extra : '')); }
}
function itemsOf(text) { return extractMath(text).items; }
// ============ 1) 用户原始样本(部分供应商单括号格式)============
const userSample = '上面是推导结果:\n[\nP_4=\\operatorname{BRF}(M_4,M_5)\n]\n\n[\nP_3=\\operatorname{BRF}(S_3,P_4)\n]\n\n其中:\n[\n\\operatorname{BRF}(L,H)\nL+\n\\Gamma(L,\\operatorname{Up}(H))\n\\odot\n\\Phi(\\operatorname{Up}(H))\n]\n';
{
const ex = extractMath(userSample);
check('用户样本: 抽出 3 个块公式', ex.items.length === 3, 'got=' + ex.items.length);
check('用户样本: 全是 display', ex.items.every(i => i.display));
check('用户样本: 占位符已就位', /@@K[BI]MA/.test(ex.md));
check('用户样本: 无残留括号([ ] 已吞)', !/[\[\]]/.test(ex.md), JSON.stringify(ex.md));
const html = restoreMath(ex.md, ex.items);
check('用户样本: 恢复出 3 个 KATEX 块', (html.match(/<KATEX d=1>/g) || []).length === 3);
check('用户样本: tex 内容正确', ex.items[0].tex.includes('P_4=\\operatorname{BRF}(M_4,M_5)'));
}
// ============ 2) 标准定界符 ============
check('$$ 块', itemsOf('a $$x^2+y^2$$ b').length === 1);
check('$$ 块 display', itemsOf('$$x$$')[0].display === true);
check('\\[ \\] 块', itemsOf('\\[E=mc^2\\]').length === 1);
check('行内 $x^2$', itemsOf('能量 $E=mc^2$ 著名').length === 1);
check('行内非 display', itemsOf('$x^2$')[0].display === false);
check('\\( \\) 行内', itemsOf('\\(a+b\\)').length === 1);
check('多公式同段', itemsOf('$a^2$ 和 $$b^2$$ 及 $c$').length === 3);
// ============ 3) 防误伤 ============
check('编号 [1] 不抽', itemsOf('见[1]参考').length === 0);
check('链接 [text](url) 不抽', itemsOf('[text](http://a.b) 和 [\nfoo\n](x)').length === 0);
check('列表 [a, b] 不抽', itemsOf('[a, b] 数组').length === 0);
check('货币 $1,000 and $2,000 不抽', itemsOf('价格 $1,000 and $2,000').length === 0);
check('单词 $a$ 按变量公式(有意取舍)', itemsOf('花 $a$ 朵').length === 1);
check('多词 $a b$ 不抽', itemsOf('这 $a b$ 个').length === 0);
check('未闭合 $x 不抽', itemsOf('价格 $x 未闭合').length === 0);
check('代码块内 $y$ [0] 不抽', itemsOf('```\nx = $y$\narr[0]\n```').length === 0);
check('行内代码 `$z$` 不抽', itemsOf('使用 `$z$` 变量').length === 0);
check('未闭合围栏内不抽', itemsOf('```\n$w$ 流式中').length === 0);
// ============ 4) 混合定位 ============
{
const ex = extractMath('前文\n\n$$\na=b\n$$\n\n后文 $c$ 尾');
check('混合: 2 公式', ex.items.length === 2);
check('混合: 占位符顺序', ex.md.indexOf('@@KBMA0@@') < ex.md.indexOf('@@KMIA1@@'));
}
// ============ 5) findUnclosedMathFrom ============
check('无公式 → 0', findUnclosedMathFrom('hello\nworld\n') === 0);
check('未闭合 $$ → 位置', findUnclosedMathFrom('a\n$$x+y\n') === 2);
check('闭合 $$ → 0', findUnclosedMathFrom('a\n$$x+y\n$$\n') === 0);
check('未闭合 [ 行 → 位置', findUnclosedMathFrom('a\n[\nx=1\n') === 2);
check('闭合 [..] → 0', findUnclosedMathFrom('a\n[\nx=1\n]\n') === 0);
check('链接 [t](u) 不算开 → 0', findUnclosedMathFrom('a\n[t](u)\n') === 0);
check('行内未闭合 $ → 位置', findUnclosedMathFrom('cost $5 plus') === 5);
check('代码围栏内忽略', findUnclosedMathFrom('```\n$$x\n```\n') === 0);
// ============ 6) computeSafeLen 公式感知回退 ============
{
// “para1\n\n” = 7 字符(5 + 两个换行)。未闭合 [ 块在边界之后 → 稳定区止于 7
const s1 = 'para1\n\n[\nx=1\n';
check('流式: 未闭合 [ 前缀止于空行', computeSafeLen(s1, 0) === 7, 'got=' + computeSafeLen(s1, 0));
check('流式: 增量无进展返回 0', computeSafeLen(s1, 7) === 0);
// 闭合后 → 整个块可固化
const s2 = 'para1\n\n[\nx=1\n]\n\n';
check('流式: 闭合后可固化全部', computeSafeLen(s2, 0) === s2.length, 'got=' + computeSafeLen(s2, 0));
// 关键回退:块边界(空行)在 未闭合 $$ 之后 → 必须回退到 $$ 起点
const s3 = 'a b\n\n$$x +\ny z\n\n';
check('流式: 未闭合 $$ 触发回退到起点', computeSafeLen(s3, 0) === 5, 'got=' + computeSafeLen(s3, 0));
// 闭合 $$ 后不再回退
const s3b = 'a b\n\n$$x +\ny z$$\n\n';
check('流式: 闭合 $$ 可固化全部', computeSafeLen(s3b, 0) === s3b.length, 'got=' + computeSafeLen(s3b, 0));
// 链接不触发回退(candidate 到链接后的空行 15;若误判未闭合会回退到 7)
const s4 = 'para1\n\n[t](u)\n\ndone\n';
check('流式: 链接不触发回退', computeSafeLen(s4, 0) === 15, 'got=' + computeSafeLen(s4, 0));
}
console.log('\n===== ' + pass + ' passed, ' + fail + ' failed =====');
process.exit(fail ? 1 : 0);