Jelajahi Sumber

修相邻字面量粘成一个符号(会静默算错);键盘换两个常量键

## 主要修复:xn / PiE 会被当成一个变量
Symja 里 `xy` 是"叫 xy 的变量",不是 x*y。而序列化是直接拼接的,
所以"敲 x 再敲 n"会得到 `xn` —— 一个未定义符号,**不报错,默默算成
别的东西**。实测:
    x*n  → 6        x n → 6        xn → xn   ❌
    PiE  → pie      ❌
    2x   → 4        ✅(数字+字母是特例,Mathematica 认它是乘法)

现在相邻两段"词字符"相接时补 `*`,左边是**纯数字**时除外(保住 `2x`
这种更像人写的写法)。函数调用后面不补(`Sin[x]y` Symja 本来就认)。

这个 bug 是加 π/e 键时才撞出来的 —— 正好说明"两个挨着的字母"这条路径
以前几乎没被走到过。

## 键盘调整
  · `/` 与 `!` 换位
  · 去掉 `_`(模式占位符,但没有方括号键时定义不了函数,是个摆设)
    和 `},{`(换行键)—— 腾出的两个位置
  · 补上 `π` 和 `e`(编辑框显示 π/e,送进引擎的是 Pi/E,复用 ANS 那套显示名机制)
  · `b` 换成 `y`:二元方程组、圆的方程、对称变换全都要 y,b 没有独占用途

代价:多行矩阵现在要把光标点回外层花括号再手动加逗号和 `{`(换行键没了)。

92 个测试通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
nonvioce 4 minggu lalu
induk
melakukan
c8f9197bc9
4 mengubah file dengan 83 tambahan dan 86 penghapusan
  1. 8 8
      lib/calculator_page.dart
  2. 26 34
      lib/expr_node.dart
  3. 44 37
      test/expr_node_test.dart
  4. 5 7
      test/widget_test.dart

+ 8 - 8
lib/calculator_page.dart

@@ -280,8 +280,6 @@ class _CalculatorPageState extends State<CalculatorPage> {
         _doc.makePower();
       case _Act.brace:
         _doc.makeBrace();
-      case _Act.newRow:
-        _doc.addMatrixRow();
       case _Act.frac:
         _doc.makeFraction();
       case _Act.postfix:
@@ -504,7 +502,9 @@ class _CalculatorPageState extends State<CalculatorPage> {
           [
             _Key('x', _Act.char, payload: 'x'),
             _Key('a', _Act.char, payload: 'a'),
-            _Key('b', _Act.char, payload: 'b'),
+            // y 比 b 要紧:二元方程组、圆的方程、对称变换全都要 y,
+            // 而 b 在函数面板之外也没什么独占用途
+            _Key('y', _Act.char, payload: 'y'),
             _Key('t', _Act.char, payload: 't'),
             _Key('n', _Act.char, payload: 'n'),
           ],
@@ -515,7 +515,8 @@ class _CalculatorPageState extends State<CalculatorPage> {
             _Key('==', _Act.char, payload: '==', tint: true),
             _Key('/.', _Act.char, payload: '/.', tint: true),
             _Key('->', _Act.char, payload: '->', tint: true),
-            _Key('_', _Act.char, payload: '_', tint: true),
+            // 常量。编辑框显示 π / e,送进引擎的是 Pi / E(和 ANS 同一套机制)
+            _Key('π', _Act.char, payload: 'Pi', tint: true, display: 'π'),
           ],
           // 5 数字与括号
           [
@@ -523,14 +524,14 @@ class _CalculatorPageState extends State<CalculatorPage> {
             _Key('8', _Act.char, payload: '8', mergeDigit: true),
             _Key('9', _Act.char, payload: '9', mergeDigit: true),
             _Key('(', _Act.group),
-            _Key('/', _Act.frac),
+            _Key('!', _Act.postfix),
           ],
           [
             _Key('4', _Act.char, payload: '4', mergeDigit: true),
             _Key('5', _Act.char, payload: '5', mergeDigit: true),
             _Key('6', _Act.char, payload: '6', mergeDigit: true),
             _Key('×', _Act.char, payload: '*'),
-            _Key('!', _Act.postfix),
+            _Key('/', _Act.frac),
           ],
           [
             _Key('1', _Act.char, payload: '1', mergeDigit: true),
@@ -545,7 +546,7 @@ class _CalculatorPageState extends State<CalculatorPage> {
             _Key('.', _Act.char, payload: '.', mergeDigit: true),
             _Key('^', _Act.power),
             _Key('{', _Act.brace),
-            _Key('},{', _Act.newRow, tint: true),
+            _Key('e', _Act.char, payload: 'E', tint: true, display: 'e'),
           ],
           [
             _Key('⌫', _Act.back, tint: true),
@@ -624,7 +625,6 @@ enum _Act {
   fnPrefixed,
   bigOp,
   brace,
-  newRow,
   sqrt,
   group,
   power,

+ 26 - 34
lib/expr_node.dart

@@ -356,39 +356,6 @@ class ExprDoc {
   /// 插入一对花括号(光标停在里面)。矩阵的每一行、整个矩阵本身都是花括号。
   void makeBrace() => insert(BraceNode(SeqNode()), enterSlot: 0);
 
-  /// 矩阵换行。
-  ///
-  /// `{{1,2},{3,4}}` 的结构是「**外层**花括号里放若干**行**花括号,行之间用逗号」,
-  /// 所以换行不是"插两个字符",而是在外层那个槽里追加「逗号 + 一个新的空花括号」,
-  /// 光标进新行。这样结构始终成立,不会出现 `{`、`}` 各插一个对不上的情况。
-  ///
-  /// 光标不在花括号里时什么都不做。
-  void addMatrixRow() {
-    // 沿光标路径往回找**最外层**的那个花括号
-    SlotPath? braceParent;
-    int braceIndex = -1;
-    BraceNode? brace;
-    for (var k = cursor.steps.length - 1; k >= 0; k--) {
-      final step = cursor.steps[k];
-      final path = SlotPath(cursor.steps.sublist(0, k), step.child);
-      final siblings = slotAt(path);
-      if (step.child >= siblings.children.length) continue;
-      final node = siblings.children[step.child];
-      if (node is BraceNode) {
-        braceParent = path;
-        braceIndex = step.child;
-        brace = node;
-      }
-    }
-    if (braceParent == null || brace == null) return;
-
-    brace.inner.children.add(LitNode(', '));
-    brace.inner.children.add(BraceNode(SeqNode()));
-    cursor = braceParent
-        .push(braceIndex, 0) // 进外层花括号的内部槽
-        .push(brace.inner.children.length - 1, 0); // 进新那一行的内部槽
-  }
-
   /// 插入一对括号(光标停在里面)。
   void makeGroup() => insert(GroupNode(SeqNode()), enterSlot: 0);
 
@@ -545,7 +512,32 @@ class ExprDoc {
   /// 槽 → 表达式。空槽在 Symja 里写成 `Null`,求值时会明确报错而不是静默算错。
   String serializeSeq(SeqNode seq) {
     if (seq.isEmpty) return 'Null';
-    return seq.children.map(serialize).join();
+    final parts = seq.children.map(serialize).toList();
+    final sb = StringBuffer();
+    for (var k = 0; k < parts.length; k++) {
+      if (k > 0) sb.write(_joiner(parts[k - 1], parts[k]));
+      sb.write(parts[k]);
+    }
+    return sb.toString();
+  }
+
+  static final _wordChar = RegExp(r'[A-Za-z0-9_]');
+  static final _pureNumber = RegExp(r'^[0-9.]+$');
+
+  /// 相邻两段之间要不要补乘号。
+  ///
+  /// 为什么必须有:Symja 里 `xy` 是「叫 xy 的变量」,不是 x*y;`xn`、`PiE`
+  /// 同理。直接拼接会让"敲 x 再敲 n"变成一个未定义符号 —— **不报错,
+  /// 默默算成别的东西**,是最难发现的那种错。
+  ///
+  /// 例外:左边是**纯数字**时不补 —— Mathematica 里 `2x` 就是 2*x,
+  /// 这样小字那行也更像人写的。
+  static String _joiner(String left, String right) {
+    if (left.isEmpty || right.isEmpty) return '';
+    if (!_wordChar.hasMatch(left[left.length - 1])) return '';
+    if (!_wordChar.hasMatch(right[0])) return '';
+    if (_pureNumber.hasMatch(left)) return '';
+    return '*';
   }
 
   String serialize(ExprNode node) => switch (node) {

+ 44 - 37
test/expr_node_test.dart

@@ -38,6 +38,47 @@ void main() {
       expect(d.toSymja(), '3.14');
     });
 
+    test('相邻两个字面量要补乘号,否则会粘成一个符号', () {
+      // Symja 里 xn 是"叫 xn 的变量",不是 x*n;PiE 同理
+      final d = ExprDoc();
+      d.insert(LitNode('x'));
+      d.insert(LitNode('n'));
+      expect(d.toSymja(), 'x*n');
+
+      final p = ExprDoc();
+      p.insert(LitNode('Pi'));
+      p.insert(LitNode('E'));
+      expect(p.toSymja(), 'Pi*E');
+    });
+
+    test('字母后面接数字也要补乘号', () {
+      final d = ExprDoc();
+      d.insert(LitNode('x'));
+      d.insertChar('2');
+      expect(d.toSymja(), 'x*2');
+    });
+
+    test('数字后面接字母保留 2x 的写法(Mathematica 认它是乘法)', () {
+      final d = ExprDoc();
+      d.insertChar('2');
+      d.insert(LitNode('x'));
+      expect(d.toSymja(), '2x');
+    });
+
+    test('运算符两侧不补乘号', () {
+      final d = docWith([LitNode('x'), LitNode('+'), LitNode('1')]);
+      expect(d.toSymja(), 'x+1');
+    });
+
+    test('函数调用后面接字母不补(Symja 认它是乘法)', () {
+      final d = ExprDoc();
+      d.makeFunction('Sin');
+      d.insertChar('x'); // 光标退回根槽前先把参数填好
+      d.cursor = const SlotPath(<SlotStep>[], 1);
+      d.insert(LitNode('y'));
+      expect(d.toSymja(), 'Sin[x]y');
+    });
+
     test('数字后面的字母不会被并进去', () {
       final d = ExprDoc();
       d.insertChar('2');
@@ -342,47 +383,13 @@ void main() {
       expect(d.toSymja(), '{1}');
     });
 
-    test('拼出一个 2x2 矩阵', () {
+    test('花括号里能直接打逗号(矩阵行分隔)', () {
       final d = ExprDoc();
-      d.makeBrace(); // 外层
-      d.makeBrace(); // 第一行
+      d.makeBrace();
       d.insertChar('1');
       d.insert(LitNode(','));
       d.insertChar('2');
-      d.addMatrixRow(); // 换行
-      d.insertChar('3');
-      d.insert(LitNode(','));
-      d.insertChar('4');
-      expect(d.toSymja(), '{{1,2}, {3,4}}');
-    });
-
-    test('换行是结构操作:新行是花括号,不是两个字符', () {
-      final d = ExprDoc();
-      d.makeBrace();
-      d.makeBrace();
-      d.addMatrixRow();
-      final outer = d.root.children.single as BraceNode;
-      // 外层里应当是「行花括号、逗号、新行花括号」
-      expect(outer.inner.children.whereType<BraceNode>().length, 2);
-      expect(outer.inner.children.whereType<LitNode>().length, 1);
-    });
-
-    test('不在花括号里按换行键什么都不做', () {
-      final d = ExprDoc();
-      d.insertChar('1');
-      d.addMatrixRow();
-      expect(d.toSymja(), '1');
-    });
-
-    test('三层嵌套时换行加在**最外层**', () {
-      final d = ExprDoc();
-      d.makeBrace(); // 外层
-      d.makeBrace(); // 行
-      d.makeBrace(); // 行里再嵌一层
-      d.addMatrixRow();
-      final outer = d.root.children.single as BraceNode;
-      expect(outer.inner.children.whereType<BraceNode>().length, 2,
-          reason: '新行应该加到最外层,不是嵌套的那一层');
+      expect(d.toSymja(), '{1,2}');
     });
   });
 

+ 5 - 7
test/widget_test.dart

@@ -233,7 +233,8 @@ void main() {
       await tester.pumpAndSettle();
 
       await press(tester, '2');
-      expect(await lastExpr(tester), 'x2+');
+      // 字母后面接数字要补乘号(否则 x2 会被当成"叫 x2 的变量")
+      expect(await lastExpr(tester), 'x*2+');
     });
 
     testWidgets('用新加的运算符键敲出一个可解的方程', (tester) async {
@@ -261,18 +262,15 @@ void main() {
       await tester.tap(find.widgetWithText(ListTile, 'Det'));
       await tester.pumpAndSettle();
 
-      // 两层花括号 + 数字
+      // 拼一行矩阵:{{1,2}}
+      // (没有"换行键"了,多行矩阵要把光标点回外层花括号再加逗号和 `{`)
       await press(tester, '{');
       await press(tester, '{');
       await press(tester, '1');
       await press(tester, ',');
       await press(tester, '2');
-      await press(tester, '},{');
-      await press(tester, '3');
-      await press(tester, ',');
-      await press(tester, '4');
 
-      expect(await lastExpr(tester), 'Det[{{1,2}, {3,4}}]');
+      expect(await lastExpr(tester), 'Det[{{1,2}}]');
     });
 
     testWidgets('赋值:x = 1', (tester) async {