// 结构化表达式模型的测试。 // // 这套东西的难点全在编辑手感上(光标往哪落、退格先钻还是先删、括号什么时候要加), // 而这些"手感"出了问题在界面上看不出来,只会悄悄算错。所以边界都钉在这里。 import 'package:flutter_test/flutter_test.dart'; import 'package:cal/expr_node.dart'; ExprDoc docWith(List nodes) { final d = ExprDoc(); for (final n in nodes) { d.insert(n); } return d; } void main() { group('插入与序列化', () { test('挨着插入的节点直接拼接', () { final d = docWith([LitNode('x'), LitNode('+'), LitNode('1')]); expect(d.toSymja(), 'x+1'); }); test('数字键并入左边那个数字,42 是一个节点不是两个', () { final d = ExprDoc(); d.insertChar('4'); d.insertChar('2'); expect(d.root.children.length, 1); expect(d.toSymja(), '42'); }); test('小数点也并入', () { final d = ExprDoc(); d.insertChar('3'); d.insertChar('.'); d.insertChar('1'); d.insertChar('4'); expect(d.toSymja(), '3.14'); }); test('数字后面的字母不会被并进去', () { final d = ExprDoc(); d.insertChar('2'); d.insertChar('x'); expect(d.root.children.length, 2); expect(d.toSymja(), '2x'); }); test('空槽序列化成 Null(让求值明确报错,而不是静默算错)', () { final d = ExprDoc(); expect(d.toSymja(), 'Null'); }); }); group('幂', () { test('先打 x 再按 ^ ,x 成为底、光标落到指数', () { final d = docWith([LitNode('x')]); d.makePower(); d.insertChar('2'); expect(d.toSymja(), 'x^2'); }); test('没有左边的节点时造一个空底的幂', () { final d = ExprDoc(); d.makePower(); d.insertChar('2'); expect(d.toSymja(), 'Null^2'); }); test('底是复合表达式时要加括号', () { final d = ExprDoc(); d.insert(PowNode( base: SeqNode([LitNode('x'), LitNode('+'), LitNode('1')]), exp: SeqNode([LitNode('2')]), )); expect(d.toSymja(), '(x+1)^2'); }); test('底是单个字面量时不加多余括号', () { final d = ExprDoc(); d.insert(PowNode(base: SeqNode([LitNode('x')]), exp: SeqNode([LitNode('2')]))); expect(d.toSymja(), 'x^2'); }); }); group('分式与括号', () { test('1 再按 / 再打 3 得 1/3', () { final d = ExprDoc(); d.insertChar('1'); d.makeFraction(); d.insertChar('3'); expect(d.toSymja(), '1/3'); }); test('分母是 x+1 时要加括号', () { final d = ExprDoc(); d.insertChar('1'); d.makeFraction(); d.insertChar('x'); d.insertChar('+'); d.insertChar('1'); expect(d.toSymja(), '1/(x+1)'); }); test('括号分组', () { final d = ExprDoc(); d.insertChar('2'); d.insertChar('*'); d.makeGroup(); d.insertChar('x'); d.insertChar('+'); d.insertChar('1'); expect(d.toSymja(), '2*(x+1)'); }); }); group('函数模板', () { test('空的 prefill 就是空槽,光标落在第一个槽', () { final d = ExprDoc(); d.makeFunction('Integrate', prefill: ['', 'x']); expect(d.toSymja(), 'Integrate[Null, x]'); // 光标应当在第一个参数里,所以接着打的字进那个槽 d.insertChar('x'); expect(d.toSymja(), 'Integrate[x, x]'); }); test('填出 Integrate[x^2, x]', () { final d = ExprDoc(); d.makeFunction('Integrate', prefill: ['', 'x']); d.insertChar('x'); d.makePower(); d.insertChar('2'); expect(d.toSymja(), 'Integrate[x^2, x]'); }); test('无参模板', () { final d = ExprDoc(); d.makeFunction('Sin'); expect(d.toSymja(), 'Sin[Null]'); d.insertChar('x'); expect(d.toSymja(), 'Sin[x]'); }); test('逗号切到下一个参数,没有就新建', () { final d = ExprDoc(); d.makeFunction('f'); d.insertChar('1'); d.nextArgument(); d.insertChar('2'); expect(d.toSymja(), 'f[1, 2]'); }); test('根号键插空模板,往里填即得', () { final d = ExprDoc(); d.makeFunction('Sqrt'); expect(d.toSymja(), 'Sqrt[Null]'); d.insertChar('2'); expect(d.toSymja(), 'Sqrt[2]'); }); }); group('退格', () { test('删掉左边那个字面量', () { final d = docWith([LitNode('1'), LitNode('2')]); d.backspace(); expect(d.toSymja(), '1'); }); test('光标在最左边时按退格什么都不发生', () { final d = docWith([LitNode('1')]); d.cursor = SlotPath.root; // 手动把光标移到最左 d.backspace(); expect(d.toSymja(), '1'); }); test('数字逐位删,不是整个数清掉', () { final d = ExprDoc(); d.insertChar('1'); d.insertChar('2'); d.insertChar('3'); d.backspace(); expect(d.toSymja(), '12'); d.backspace(); expect(d.toSymja(), '1'); }); test('住进空槽里退格:先退回父槽,顺手把没填完的模板也删掉', () { final d = ExprDoc(); d.makeFunction('Integrate', prefill: ['', 'x']); expect(d.toSymja(), 'Integrate[Null, x]'); d.backspace(); // 空槽最左边 → 退回根槽,并把没填完的模板一起删掉 expect(d.toSymja(), 'Null'); }); test('左边是没填完的模板:先进去,再退一次才删掉', () { final d = ExprDoc(); d.makeFunction('Integrate', prefill: ['', 'x']); d.cursor = const SlotPath([], 1); // 根槽 offset 1 = 模板右边 expect(d.toSymja(), 'Integrate[Null, x]'); d.backspace(); // 钻进那个空槽,内容不变 expect(d.toSymja(), 'Integrate[Null, x]'); d.backspace(); // 退回根槽,顺手把没填完的模板删掉 expect(d.toSymja(), 'Null'); }); test('填完参数的过程里模板不会被误删', () { final d = ExprDoc(); d.makeFunction('Sin'); d.insertChar('x'); expect(d.toSymja(), 'Sin[x]'); // 回到参数里,且把光标停在 x **之后**(offset 1) d.cursor = const SlotPath([SlotStep(0, 0)], 1); d.backspace(); // 删参数里的 x expect(d.toSymja(), 'Sin[Null]'); }); test('左边是填完的模板:整个删掉(否则光标永远出不来)', () { final d = ExprDoc(); d.makeFunction('Integrate', prefill: ['', 'x']); d.insertChar('x'); // Integrate[x, x],已经填完 d.cursor = const SlotPath([], 1); // 模板右边 expect(d.toSymja(), 'Integrate[x, x]'); d.backspace(); expect(d.toSymja(), 'Null'); }); }); group('光标路径', () { test('插入带占位槽的节点后光标在正确的槽里', () { final d = ExprDoc(); d.makeFraction(); expect(d.cursor.steps.length, 1); expect(d.cursor.steps.first.slot, 1); // 分母 expect(d.cursor.offset, 0); }); test('parent 回到父槽并停在刚退出的那个孩子之后', () { final d = ExprDoc(); d.makeFunction('Sin'); d.insertChar('x'); final up = d.cursor.parent; expect(up, isNotNull); expect(up!.steps, isEmpty); expect(up.offset, 1); // Sin 之后 }); }); group('选区与剪切粘贴', () { ExprDoc twoXPlusOne() { final d = ExprDoc(); d.insertChar('2'); d.insert(LitNode('x')); d.insert(LitNode('+')); d.insertChar('1'); return d; } test('选中一个节点', () { final d = twoXPlusOne(); d.selectNodeAt(SlotPath.root, 0); expect(d.selectionText, '2'); }); test('扩展选区覆盖连续节点', () { final d = twoXPlusOne(); d.selectNodeAt(SlotPath.root, 1); // x d.extendSelectionTo(SlotPath.root, 3); // 1 expect(d.selectionText, 'x+1'); }); test('往左扩展也成立(start 取更小的那个)', () { final d = twoXPlusOne(); d.selectNodeAt(SlotPath.root, 3); d.extendSelectionTo(SlotPath.root, 0); expect(d.selectionText, '2x+1'); }); test('剪切:返回文本、删掉内容、光标落到原位', () { final d = twoXPlusOne(); d.selectNodeAt(SlotPath.root, 0); d.extendSelectionTo(SlotPath.root, 3); expect(d.selectionText, '2x+1'); d.deleteSelection(); expect(d.toSymja(), 'Null'); expect(d.selection, isNull); expect(d.cursor.offset, 0); }); test('只删中间一段,两边保留', () { final d = twoXPlusOne(); d.cursor = const SlotPath([], 0); d.selectNodeAt(SlotPath.root, 1); // 选中 x 与 + d.extendSelectionTo(SlotPath.root, 2); expect(d.selectionText, 'x+'); d.deleteSelection(); expect(d.toSymja(), '21'); }); test('有选区时按退格 = 删掉选中的那段', () { final d = twoXPlusOne(); d.selectNodeAt(SlotPath.root, 1); // x d.extendSelectionTo(SlotPath.root, 2); // + expect(d.selectionText, 'x+'); d.backspace(); expect(d.toSymja(), '21'); expect(d.selection, isNull); }); test('没有选区时退格还是老行为', () { final d = twoXPlusOne(); d.backspace(); expect(d.toSymja(), '2x+'); }); test('插入到光标处(粘贴进 Log 的括号里)', () { final d = ExprDoc(); d.makeFunction('Log'); // Log[□],光标在参数槽里 d.insertNodesAtCursor([LitNode('2'), LitNode('x'), LitNode('+'), LitNode('1')]); expect(d.toSymja(), 'Log[2x+1]'); expect(d.cursor.offset, 4); }); test('跨槽扩展会被忽略,不会串到别的槽', () { final d = ExprDoc(); d.makeFunction('Log'); // 光标在 Log 的参数槽 d.selectNodeAt(SlotPath.root, 0); // 选中根槽里的 Log d.extendSelectionTo(const SlotPath([SlotStep(0, 0)], 0), 0); expect(d.selectionText, 'Log[Null]'); // 还是原来那个选区 }); }); group('光标槽判定', () { test('槽内位置不同,仍算同一个槽(渲染光标靠这个)', () { const a = SlotPath([], 0); const b = SlotPath([], 3); expect(a == b, isFalse); // == 比的是完整位置 expect(a.sameSlotAs(b), isTrue); // sameSlotAs 只比槽 }); test('不同槽不算同一个', () { const a = SlotPath([SlotStep(0, 0)], 1); const b = SlotPath([SlotStep(0, 1)], 1); expect(a.sameSlotAs(b), isFalse); }); test('打一个字之后,光标仍被认为在根槽里', () { final d = ExprDoc(); expect(d.cursor.sameSlotAs(SlotPath.root), isTrue); d.insertChar('x'); expect(d.cursor.offset, 1); expect(d.cursor.sameSlotAs(SlotPath.root), isTrue, reason: 'offset 变了但槽没变,光标必须还画得出来'); }); test('进到函数参数里后,根槽不再算聚焦', () { final d = ExprDoc(); d.makeFunction('Sin'); expect(d.cursor.sameSlotAs(SlotPath.root), isFalse); expect( d.cursor.sameSlotAs(const SlotPath([SlotStep(0, 0)], 0)), isTrue, ); }); }); group('clear', () { test('清空后回到根槽', () { final d = ExprDoc(); d.makeFunction('Integrate', prefill: ['', 'x']); d.clear(); expect(d.toSymja(), 'Null'); expect(d.cursor.steps, isEmpty); expect(d.root.children, isEmpty); }); }); }