diff --git a/README.en.md b/README.en.md
index a09dc712..51fcea63 100644
--- a/README.en.md
+++ b/README.en.md
@@ -44,7 +44,7 @@ right here, with the URL, the header and the data never passing through our serv
-
+
@@ -65,7 +65,7 @@ self-hosted, straight from PostgreSQL/MySQL — one saved read-only query, and n
and full-fidelity Excel/PDF export, where a chart exported to `.xlsx` is a real, editable chart
bound to its cells, because the OOXML chart parts are written by hand (ExcelJS writes none). Plus optional
bring-your-own-backend cloud save and live co-editing over it — presence, last-writer-wins with the loser told, and
-an undo that does not erase the other person's work. Bilingual UI (Thai/English), 1979 automated tests.
+an undo that does not erase the other person's work. Bilingual UI (Thai/English), 1985 automated tests.
---
@@ -109,7 +109,7 @@ Want the harder parts: [embedding a Thai font in the PDF, with stacked tone mark
---
-### 🧪 What 1979 passing tests could not catch
+### 🧪 What 1985 passing tests could not catch
Every test of the assistant **mocks the model** — it returns what I imagined it would. Put a real
API key behind it, ask fourteen ordinary questions, and **six answers used functions this engine
@@ -120,7 +120,7 @@ Then **the first fix made it worse.** The rule started as "give the closest form
allows", so _"join all the names into one line"_ came back as `=SUM(A2:A20)` — `0` in the cell, no
error, nothing to notice. **A visible `#NAME?` traded for an invisible wrong number.**
-**And it happened again, in a different place.** With every gate green — 1979 tests, `axe` clean on
+**And it happened again, in a different place.** With every gate green — 1985 tests, `axe` clean on
both pages at two widths — an hour of clicking through the public build the way a first-time visitor
would found three things no gate can see:
@@ -395,7 +395,7 @@ Other available commands:
| `npm run build` | Build a production bundle |
| `npm run start` | Run the production build (run `npm run build` first) |
| `npm run lint` | Check code quality with ESLint |
-| `npm test` | Run the 1979-case Vitest suite |
+| `npm test` | Run the 1985-case Vitest suite |
| `npm run check:readme` | Check the READMEs still match the code (links/images/test count/new modules/both languages) |
| `npm run check:screens` | Figures printed on a screenshot still match the source |
| `npm run check:rls` | Two real accounts against your own Supabase: does the database refuse what the policies say it should (needs env) |
@@ -1253,6 +1253,7 @@ share one rule (`src/lib/cellLiteral.ts`), in this order:
| A cell formatted as **Text** (number format menu · Excel's `@`) | `123` | text — for a whole column of codes |
| An integer with a leading zero | `0812345678`, `00123` | text, automatically (`0` and `0.5` are still numbers) |
| Twelve digits or more, nothing else | `1234567890123` | text, automatically (eleven digits or fewer, or with a point or sign, are numbers) |
+| One of Excel's seven error codes, in any case (#226) | `#N/A`, `#DIV/0!`, `#n/a` | **the error value**, as in Excel — `#1`, `#N/A please` or `#SPILL!` stay text |
| A number as a screen shows it — commas at the thousands, a trailing `%`, or a currency sign in front (#52) | `1,250`, `12%`, `฿1,500.00` | **a number** — `12%` is 0.12 (as #53 stores it) · the cell shows what was typed |
| Everything else | `-3`, `12.50`, `10000000000` | a number, as before |
@@ -2518,7 +2519,6 @@ package again as it opens and **says so before anything is edited**: what, how m
| Excel PivotTables | values only | static numbers, not a PivotTable |
| Formulas calling a function the engine lacks | `#NAME?` | **the formula is kept**, and Excel calculates it |
| Range names that cannot come in (a formula, a whole column, several areas, or a sheet the file lacks) | formulas using them read `#NAME?` | the name is lost; formulas still carry it |
-| Cells holding a constant error value (`#DIV/0!`, `#N/A`) | shown as the text of the code; a formula that reads one does not see an error, so `SUM` steps over it (until [#226](https://github.com/SuruchBoss/ExcelToGo/issues/226)) | **written back as the error value** |
| External links to other files | values do not update | lost |
| Macros (VBA) | not run | lost |
@@ -2526,8 +2526,8 @@ package again as it opens and **says so before anything is edited**: what, how m
- **A plain file sees nothing.** The report comes up only when something is actually lost.
- **A constant error cell used to open blank, with nothing said** (#227), and a total over it read as a clean number. It now
- keeps its code and goes back out as the error it was. An error code typed as Excel spells it (`#N/A`, `#DIV/0!`) also
- goes out as the error, as Excel would store it; a leading apostrophe or the Text format keeps it text.
+ opens as the error value itself (#226), so a formula that reads it sees an error as in Excel, and it goes back out as
+ the error it was. Nothing about it is lost, so it is no longer in this report.
- **Got it** closes it. It opens again from the **menu** (or the amber button beside Export Excel from 1366px) for as long
as the file's sheets are open; undoing the open, or opening another file over it, takes the report away too.
- It reads the original package, not what the importer returned, because what the importer returned is exactly what
@@ -3023,7 +3023,7 @@ architecture behind it.
| `@anthropic-ai/sdk` | Connects to the Claude API for the AI assistant |
| `lucide-react` | UI icons |
| `clsx` | Conditional className composition |
-| `vitest` | Unit tests for the formula engine, sort logic, JSON-to-table conversion, pagination, rate limiting, templates, file fidelity, conditional formatting and live blocks (1979 cases) |
+| `vitest` | Unit tests for the formula engine, sort logic, JSON-to-table conversion, pagination, rate limiting, templates, file fidelity, conditional formatting and live blocks (1985 cases) |
> **Note:** No off-the-shelf formula library (e.g. HyperFormula) is used — the **formula engine is hand-written**
> (tokenizer, parser, evaluator, and functions) to keep full control over its behavior. See
@@ -3297,7 +3297,7 @@ src/
fillSeries.ts # What dragging the corner continues into — numbers, Thai days and months, quarters, formulas
sheetSearch.ts # Find and replace over the raw text rather than the displayed result
workbookRefs.ts # The formula rewrites that are not a fact about one sheet: cross-sheet shifts, renames
- fileLosses.ts # What an opened file holds that the app can't keep (pictures, charts, PivotTables, macros, links, missing functions, constant error values), read from the package (#83)
+ fileLosses.ts # What an opened file holds that the app can't keep (pictures, charts, PivotTables, macros, links, missing functions), read from the package (#83)
sheetNames.ts # Excel's sheet-name rules in one place: ≤31 chars, no \ / ? * [ ] :, unique ignoring case (#54)
byok.ts # The visitor's own API key: this tab only, masked when shown
sheet.ts # The core sheet data model, whole-sheet computation, applying a formula by scope,
@@ -3708,6 +3708,14 @@ shows as Infinity rather than Excel's `#NUM!`; only `^` and `POWER` were fixed.
Full support for arithmetic/comparison/concatenation operators (`+ - * / ^ = <> < > <= >= &`) and Excel-style
error values: `#DIV/0!`, `#VALUE!`, `#NAME?`, `#N/A`, `#REF!`, `#CIRCULAR!`.
+**An error code held in a cell as a value is the error itself, as in Excel (#226).** `#N/A`, `#DIV/0!`, `#VALUE!`,
+`#REF!`, `#NAME?`, `#NUM!` and `#NULL!` typed into a cell, pasted from Excel, opened from a file or written by a pivot used
+to be text, so `SUM` stepped over them to a clean-looking total and `IFERROR` let them through. Now `SUM` gives the
+error and `IFERROR`/`IFNA` catch it. A leading apostrophe, the Text format, or text that only starts with `#` (`#1`,
+`#N/A please`) stays text, and so does a file's text cell that happens to read `#N/A`, as Excel stores it. Case does
+not matter: `#n/a` is `#N/A`, as Excel makes it when it is typed. The newer codes such as `#SPILL!` stay text, because
+Excel keeps them as text when they are typed into a cell. (Both were checked in real Excel.)
+
Want to add a formula to the drag-and-drop palette? Add the real function in `functions.ts`, then add its entry
plus both languages' text in `formulaCatalog.ts` and `i18n/th.ts`/`en.ts`.
@@ -4063,13 +4071,13 @@ the framework bundle itself, which isn't a trade worth making here. Written down
## 🧪 Testing
```bash
-npm test # 1979 cases across 140 files, via Vitest
+npm test # 1985 cases across 141 files, via Vitest
```
Testing is focused on the **formula engine, sort logic, JSON-to-table conversion, pagination, rate-limit backoff, Excel templates and live-block placement** — pure functions with no React/DOM dependency, so
they run fast and give high confidence.
-**But not one of those 1979 cases opens the app**, and nearly every bug this project found by hand lived in
+**But not one of those 1985 cases opens the app**, and nearly every bug this project found by hand lived in
the wiring *between* pieces that all passed their tests — the toolbar's "+ row" called `addRow`, which
announced nothing, while `insertRowAtSelection` next to it announced correctly (both tested) · the AI
assistant sent a range including its text header, because the context builder read raw `sheet.cells`
@@ -4188,10 +4196,10 @@ once; disable `ArrowRight` in the grid and two assertions in the third fail. (Th
second one stayed green: the `case` I inserted landed *after* the existing `case "ArrowRight"` and was dead
code. Proving a gate means checking that the thing you meant to break actually broke.)
-> **1979 tests passed, and 43% of the assistant's answers were unusable** — because those tests mock
+> **1985 tests passed, and 43% of the assistant's answers were unusable** — because those tests mock
> the model, so it returns what the test author imagined. A test count says what you thought to ask,
> not whether you asked enough. Only a real API key found this: see
-> [What 1979 passing tests could not catch](#-what-1979-passing-tests-could-not-catch), repeatable
+> [What 1985 passing tests could not catch](#-what-1985-passing-tests-could-not-catch), repeatable
> with `npm run check:ai`.
| File | Cases | Tests |
@@ -4232,7 +4240,7 @@ code. Proving a gate means checking that the thing you meant to break actually b
| `sheetNames.test.ts` | 7 | Excel's sheet-name rules (#54): each rule broken, case counted as one name, cleaning a name and finding a free one, + never reusing a name after a delete, an old save's duplicate keeping its formulas on the first tab, an invalid name renamed with its formulas |
| `sheetMerges.test.ts` | 35 | Which cells a merge swallows, shifting merges on row/column insert and delete, dropping one that collapses to a single cell |
| `sheetTemplate.test.ts` | 14 | Which cells are locked vs. fields, inline and range-backed dropdown options, column-width conversion |
-| `fileLosses.test.ts` | 12 | What an opened file can't keep (#83): a real file with a picture, a chart and a function the engine lacks, counted with its sheet; that formula exported as it was; a plain file reporting nothing; `_xlfn.` spelling of a function the engine has not counted; PivotTables, shapes, macros, links and a chart sheet as the spec lays them out; a non-zip not failing the open; range names that cannot come in (#60) as one line of the same report, after the missing functions, the whole report for a file with nothing else, and still said when the package cannot be read again; constant error cells (#227) counted with their sheet while a formula whose result is an error is not, and still counted after an export and a reopen |
+| `fileLosses.test.ts` | 11 | What an opened file can't keep (#83): a real file with a picture, a chart and a function the engine lacks, counted with its sheet; that formula exported as it was; a plain file reporting nothing; `_xlfn.` spelling of a function the engine has not counted; PivotTables, shapes, macros, links and a chart sheet as the spec lays them out; a non-zip not failing the open; range names that cannot come in (#60) as one line of the same report, after the missing functions, the whole report for a file with nothing else, and still said when the package cannot be read again; constant error cells (#227) no longer reported, since the app reads them as the errors they are from #226 |
| `excelIO.test.ts` | 58 | Builds a real .xlsx and round-trips it: reading fields/dropdowns/widths, an unprotected file isn't a template, export→import comes back identical, and styling (fills/font sizes/borders/row heights/merges) round-trips, as do all five kinds of conditional formatting rule and cell notes (both the plain-string and Excel's rich-text form), cross-sheet dropdowns read from the sheet the rule names (quoted names, a hidden sheet, a missing one, and the PaynEat ERP draft-0 template), a column exactly width 9 still 9 after export (#136), a merge in the file holding its value in the top-left cell only with SUM/COUNTA matching Excel and the merge still exported (#55), a real `t="e"` cell opening as its error code rather than blank, going back out as `t="e"` and still there on a reopen, while `'#N/A` and a Text-formatted cell go out as text (#227) |
| `charts.test.ts` | 42 | Reading a range into series and labels (including a text label column), gaps for non-numbers, a zero-anchored axis, moving/resizing/clamping a chart's frame, what the legend names per kind, shifting on edits |
| `server/urlGuard.test.ts` | 31 | Addresses the server refuses to reach (loopback, private ranges, cloud metadata, IPv6 link-local), IPv4 embedded in IPv6 (`::ffff:`, `::`, SIIT, NAT64, 6to4, Teredo), non-http schemes, and the allowlist |
@@ -4259,6 +4267,7 @@ code. Proving a gate means checking that the thing you meant to break actually b
| `store/datePivot.test.ts` | 1 | A pivot by day (#45): its rows are headed with dates, not serials |
| `store/pivotSize.test.ts` | 6 | A summary sheet as big as its answer (#56): all 35 products with the Grand total row in bold, all twelve months with the Grand total column, the same after a refresh, a small result still opening on a 30×10 sheet with room to work, a result too big for a sheet built nowhere and its size said, and a refresh too big for one alerting while the old pivot stays |
| `store/pivotError.test.ts` | 2 | A pivot over a source with an error (#222): Sum, Average, Min and Max of the group holding `#DIV/0!` and the Grand total show `#DIV/0!` while the other group keeps its number, when built and when refreshed after the source gains the error |
+| `store/errorLiterals.test.ts` | 4 | An error code held in a cell as a value is the error (#226), by every way in: typed (`SUM` gives `#DIV/0!`, `IFERROR`/`IFNA` catch it), pasted as Excel copies it (`#1`, `#N/A please`, `'#N/A` stay text), opened from a file (a `t="e"` cell is the error, a string cell reading `#N/A` stays text), and `SUM`/`IFERROR` over a pivot sheet holding an error |
| `store/dateEntry.test.ts` | 5 | Entering a date as a person does (#45): `=TODAY()`/`=DATE()`/`=NOW()` formatted as dates while the cell is General, a date and time widening a default-width column, picking a date format replacing the file's layout |
| `store/convertDates.test.ts` | 5 | "Convert to dates" and "Date (B.E.)" through the store (#82): converted cells show the Buddhist year while unreadable ones and formulas stay, A.D. month-first, one undo for all, a chosen format kept, the B.E. format shown and switched back |
| `store/sampleNotice.test.ts` | 19 | Opening blank, the sample opened by its button and undone, a sample left in the browser (either language) not restored while one edit of any kind makes it work, New file undone and announced, the sample notice only while untouched, and the sample following the language on screen |
diff --git a/README.md b/README.md
index cc721cf9..466ae1b2 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@
-
+
@@ -61,7 +61,7 @@ and saying so when data came back incomplete. A range can be given a name (in Th
what it means, and a cell can carry a rule about what may go in it, refused before it is written rather than
flagged afterwards. Conditional formatting re-colours cells from their current values — comparisons,
top/bottom ranks, colour scales and data bars — and round-trips through Excel's own rule format. Bilingual UI
-(Thai/English), 1979 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง
+(Thai/English), 1985 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง
---
@@ -106,7 +106,7 @@ top/bottom ranks, colour scales and data bars — and round-trips through Excel'
---
-### 🧪 สิ่งที่เทสต์ 1979 เคสจับไม่ได้
+### 🧪 สิ่งที่เทสต์ 1985 เคสจับไม่ได้
เทสต์ของผู้ช่วย AI ใช้ **mock ทั้งหมด** — มันตอบตามที่ผมคิดว่ามันจะตอบ พอเอา API key จริงมายิงคำถามธรรมดา
14 ข้อ **6 คำตอบใช้ฟังก์ชันที่เอนจินนี้ไม่มี** (`TEXTJOIN` `FIND` `RANK.EQ` `SUMPRODUCT` `CEILING` `CHAR`)
@@ -116,7 +116,7 @@ top/bottom ranks, colour scales and data bars — and round-trips through Excel'
มันจึงตอบคำถาม *"ต่อชื่อทั้งหมดเป็นบรรทัดเดียว"* ด้วย `=SUM(A2:A20)` ซึ่งได้ `0` โดยไม่มีอะไรบอกว่าผิด
**เปลี่ยน error ที่มองเห็น เป็นตัวเลขผิดที่มองไม่เห็น**
-**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 1979 เทสต์ผ่าน, `axe` ไม่มี violation ทั้งสองหน้า
+**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 1985 เทสต์ผ่าน, `axe` ไม่มี violation ทั้งสองหน้า
ที่สองความกว้าง — ผมลองไล่คลิก build สาธารณะแบบคนที่เพิ่งเข้ามาครั้งแรกอยู่ชั่วโมงหนึ่ง แล้วเจอสามอย่าง
ที่ไม่มีด่านไหนมองเห็น:
@@ -381,7 +381,7 @@ npm run dev
| `npm run build` | build เป็นเวอร์ชัน production |
| `npm run start` | รันเวอร์ชันที่ build แล้ว (ต้อง `npm run build` ก่อน) |
| `npm run lint` | ตรวจสอบคุณภาพโค้ดด้วย ESLint |
-| `npm test` | รัน unit test 1979 เคสด้วย Vitest |
+| `npm test` | รัน unit test 1985 เคสด้วย Vitest |
| `npm run check:readme` | ตรวจว่า README ยังตรงกับโค้ด (ลิงก์/ภาพ/จำนวนเทสต์/โมดูลใหม่/สองภาษาตรงกัน) |
| `npm run check:screens` | ตัวเลขที่พิมพ์อยู่บนภาพหน้าจอยังตรงกับซอร์สไหม |
| `npm run check:rls` | ยิงจริงสองบัญชีบน Supabase ของคุณ ว่าฐานข้อมูลปฏิเสธตามที่ policy เขียนไว้ไหม (ต้องตั้ง env) |
@@ -1118,6 +1118,7 @@ VAT = 0.07 จึงขึ้น "0.07%" ทั้งบนจอและใน
| เซลล์รูปแบบ **ข้อความ** (เมนูรูปแบบตัวเลข · Excel `@`) | `123` | ข้อความ — เหมาะกับทั้งคอลัมน์ที่เป็นรหัส |
| จำนวนเต็มที่มีศูนย์นำหน้า | `0812345678`, `00123` | ข้อความ อัตโนมัติ (`0` กับ `0.5` ยังเป็นตัวเลข) |
| ตัวเลขล้วน 12 หลักขึ้นไป | `1234567890123` | ข้อความ อัตโนมัติ (11 หลักลงมา หรือมีจุด/เครื่องหมาย ยังเป็นตัวเลข) |
+| รหัส error เจ็ดตัวของ Excel ตัวพิมพ์ไหนก็ได้ (#226) | `#N/A`, `#DIV/0!`, `#n/a` | **ค่า error** แบบใน Excel — `#1`, `#N/A please` หรือ `#SPILL!` ยังเป็นข้อความ |
| ตัวเลขแบบที่จอแสดง — จุลภาคตรงหลักพัน, `%` ท้าย หรือสัญลักษณ์เงินข้างหน้า (#52) | `1,250`, `12%`, `฿1,500.00` | **ตัวเลข** — `12%` คือ 0.12 (แบบเดียวกับ #53) · จอแสดงตามที่พิมพ์ |
| ที่เหลือ | `-3`, `12.50`, `10000000000` | ตัวเลขเหมือนเดิม |
@@ -2208,16 +2209,14 @@ merge เลื่อนตามการแทรก/ลบแถวและ
| Pivot Table ของ Excel | เห็นแค่ค่า | ค่าเป็นตัวเลขนิ่ง ไม่ใช่ Pivot Table |
| สูตรที่ใช้ฟังก์ชันที่เอนจินไม่มี | ขึ้น `#NAME?` | **สูตรเดิมยังอยู่** เปิดใน Excel แล้วคำนวณได้ |
| ชื่อช่วงที่นำเข้าไม่ได้ (ชื่อที่เป็นสูตร ทั้งคอลัมน์ หลายช่วง หรือชี้ชีตที่ไม่มี) | สูตรที่ใช้ชื่อขึ้น `#NAME?` | ชื่อหายไป สูตรยังถือชื่อนั้นไว้ |
-| เซลล์ที่เก็บค่า error คงที่ (`#DIV/0!`, `#N/A`) | แสดงเป็นข้อความของรหัส สูตรที่อ่านช่องนั้นไม่เห็นว่าเป็น error `SUM` จึงข้ามไป (จนกว่า [#226](https://github.com/SuruchBoss/ExcelToGo/issues/226) จะเข้า) | **เขียนกลับเป็นค่า error เหมือนเดิม** |
| ลิงก์ไปไฟล์อื่น (external links) | ค่าไม่อัปเดต | หายไป |
| มาโคร (VBA) | ไม่รัน | หายไป |

- **ไฟล์ธรรมดาไม่เห็นอะไรเลย** รายงานขึ้นเฉพาะเมื่อเจอของที่เก็บไม่ได้จริง
-- **เซลล์ที่เก็บค่า error คงที่เคยเปิดมาเป็นช่องว่างโดยไม่มีอะไรบอก** (#227) และยอดรวมที่อ่านช่องนั้นออกมาเป็นตัวเลขดูปกติ · ตอนนี้รหัสยังอยู่
- และส่งออกกลับเป็นค่า error เหมือนเดิม · รหัส error ที่พิมพ์ตรงตามที่ Excel เขียน (`#N/A`, `#DIV/0!`) ก็ส่งออกเป็นค่า error แบบที่ Excel เก็บ
- ถ้าขึ้นต้นด้วย `'` หรือจัดรูปแบบเป็นข้อความจะยังเป็นข้อความ
+- **เซลล์ที่เก็บค่า error คงที่เคยเปิดมาเป็นช่องว่างโดยไม่มีอะไรบอก** (#227) และยอดรวมที่อ่านช่องนั้นออกมาเป็นตัวเลขดูปกติ · ตอนนี้เปิดมาเป็นค่า error
+ จริง (#226) สูตรที่อ่านช่องนั้นเห็นเป็น error แบบใน Excel และส่งออกกลับเป็นค่า error เหมือนเดิม จึงไม่อยู่ในรายงานนี้แล้ว
- กด **เข้าใจแล้ว** เพื่อปิด แล้วเปิดดูอีกครั้งได้จาก**เมนู** (หรือปุ่มสีเหลืองข้างปุ่มส่งออก Excel ตั้งแต่ 1366px) ตราบเท่าที่ชีตจากไฟล์นั้นยังเปิดอยู่
· undo การเปิดไฟล์ หรือเปิดไฟล์อื่นทับ รายงานก็หายไปด้วย
- อ่านจาก package ต้นฉบับ ไม่ใช่จากผลของตัวนำเข้า เพราะผลของตัวนำเข้าคือสิ่งที่ของพวกนี้หายไปแล้ว ·
@@ -2631,7 +2630,7 @@ stack ของ `font-mono` จึงต่อท้ายด้วย Plex Sans
| `@anthropic-ai/sdk` | เชื่อมต่อ Claude API สำหรับผู้ช่วย AI |
| `lucide-react` | ไอคอน UI |
| `clsx` | รวม className แบบมีเงื่อนไข |
-| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (1979 เคส) |
+| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (1985 เคส) |
> **หมายเหตุ:** ไม่ได้ใช้ไลบรารีคำนวณสูตรสำเร็จรูป (เช่น HyperFormula) แต่เขียน **เอนจินคำนวณสูตรขึ้นเอง**
> ทั้ง tokenizer, parser, evaluator และฟังก์ชันต่างๆ เพื่อควบคุมพฤติกรรมได้เต็มที่ ดูรายละเอียดที่หัวข้อ
@@ -2891,7 +2890,7 @@ src/
fillSeries.ts # ลากมุมแล้วได้อะไรต่อ — ชุดตัวเลข วันไทย เดือนไทย ไตรมาส และการขยับสูตร
sheetSearch.ts # ค้นหา/แทนที่จากข้อความดิบ ไม่ใช่ผลลัพธ์ที่แสดง
workbookRefs.ts # การแก้สูตรที่ไม่ใช่เรื่องของชีตเดียว — แทรกแถวข้ามชีต และเปลี่ยนชื่อชีต
- fileLosses.ts # ไฟล์ที่เปิดมามีอะไรที่แอปเก็บไว้ไม่ได้ (รูป กราฟ Pivot มาโคร ลิงก์ ฟังก์ชันที่ไม่มี ค่า error คงที่) อ่านจาก package (#83)
+ fileLosses.ts # ไฟล์ที่เปิดมามีอะไรที่แอปเก็บไว้ไม่ได้ (รูป กราฟ Pivot มาโคร ลิงก์ ฟังก์ชันที่ไม่มี) อ่านจาก package (#83)
sheetNames.ts # กติกาชื่อชีตของ Excel ที่เดียว — ≤31 ตัว, ไม่มี \ / ? * [ ] :, ไม่ซ้ำแม้ต่างตัวพิมพ์ (#54)
byok.ts # เก็บ API key ของผู้ใช้ไว้ในแท็บเดียว + ปิดบังตอนแสดงผล
sheet.ts # โมเดลข้อมูลตารางหลัก, คำนวณค่าทั้งชีต, ใส่สูตรตาม scope ต่างๆ, แทรก/ลบแถว-คอลัมน์
@@ -3251,6 +3250,13 @@ error อยู่แล้ว คือ `IF` `IFERROR` `IFNA` `COUNT` `COUNTA`
รองรับตัวดำเนินการคำนวณ/เปรียบเทียบ/ต่อข้อความครบ (`+ - * / ^ = <> < > <= >= &`) และแจ้ง error แบบ Excel:
`#DIV/0!`, `#VALUE!`, `#NAME?`, `#N/A`, `#REF!`, `#CIRCULAR!`
+**รหัส error ที่อยู่ในเซลล์เป็นค่า ก็เป็น error จริงแบบใน Excel (#226)** — `#N/A`, `#DIV/0!`, `#VALUE!`, `#REF!`, `#NAME?`, `#NUM!`
+และ `#NULL!` ที่พิมพ์ลงเซลล์ วางมาจาก Excel เปิดมาจากไฟล์ หรือที่ Pivot เขียนลงชีต เดิมเก็บเป็นข้อความ `SUM` จึงข้ามไปแล้วได้ยอดที่ดูปกติ
+และ `IFERROR` จับไม่ได้ · ตอนนี้ `SUM` ได้ error และ `IFERROR`/`IFNA` จับได้ · ขึ้นต้นด้วย `'` จัดรูปแบบเป็นข้อความ หรือแค่ขึ้นต้นด้วย `#`
+(`#1`, `#N/A please`) ยังเป็นข้อความ · ข้อความในไฟล์ที่บังเอิญเป็น `#N/A` ก็ยังเป็นข้อความตามที่ Excel เก็บ ·
+ตัวพิมพ์เล็กใหญ่ไม่มีผล `#n/a` เป็น `#N/A` เหมือนที่ Excel ทำตอนพิมพ์ ส่วนรหัสรุ่นใหม่อย่าง `#SPILL!` ยังเป็นข้อความ เพราะ Excel เองก็เก็บเป็นข้อความเมื่อพิมพ์ลงเซลล์
+(ทั้งสองข้อลองใน Excel จริงแล้ว)
+
ต้องการสูตรเพิ่มเติมในแถบลากวาง? เพิ่มฟังก์ชันจริงที่ `functions.ts` แล้วเพิ่มรายการ + คำแปล 2 ภาษาที่
`formulaCatalog.ts` + `i18n/th.ts`/`en.ts`
@@ -3528,13 +3534,13 @@ B อ่านและแก้ได้ แต่ยังยึดเป็
## 🧪 การทดสอบ
```bash
-npm test # 1979 เคส ใน 140 ไฟล์ ด้วย Vitest
+npm test # 1985 เคส ใน 141 ไฟล์ ด้วย Vitest
```
โฟกัสเทสต์ไปที่ **เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, การแปลง JSON เป็นตาราง, การไล่ดึงหน้าถัดไป, การถอยเมื่อโดน rate limit, แม่แบบจากไฟล์ Excel, กฎจัดรูปแบบตามเงื่อนไข และการวางบล็อกข้อมูลสด** — ส่วนที่เป็น pure function ล้วน ไม่ต้องพึ่ง React/DOM
จึงเทสต์ได้เร็วและมั่นใจได้สูง
-**แต่ 1979 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์
+**แต่ 1985 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์
ทุกตัวแทบทั้งหมด — ปุ่ม "+ แถว" บนแถบเครื่องมือเรียก `addRow` ที่ไม่ประกาศอะไรเลย ขณะที่ `insertRowAtSelection`
ข้าง ๆ ประกาศถูกต้อง (ทั้งคู่เทสต์ผ่าน) · ผู้ช่วย AI ส่งช่วงที่รวมหัวคอลัมน์ไปด้วย เพราะตัวประกอบ context อ่าน
`sheet.cells` ที่เป็นสูตรดิบแทนค่าที่คำนวณแล้ว (ทั้งคู่เทสต์ผ่าน) · ปุ่มใหม่หนึ่งปุ่มดันปุ่มสลับภาษาตกขอบจอไป 42px
@@ -3634,9 +3640,9 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด
ในตารางแล้วสองข้อในflow ที่สามตกทันที (ครั้งแรกที่ลองปิดยังเขียวอยู่ เพราะ `case` ที่แทรกเข้าไปไปอยู่หลัง
`case "ArrowRight"` เดิม กลายเป็นโค้ดตาย — การพิสูจน์ต้องเช็กด้วยว่าของที่ตั้งใจทำให้พังนั้นพังจริง)
-> **1979 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้
+> **1985 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้
> มันจึงตอบตามที่คนเขียนเทสต์คิดว่ามันจะตอบ จำนวนเทสต์บอกได้แค่ว่าเราถามอะไรไปบ้าง ไม่ได้บอกว่าเราถาม
-> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 1979 เคสจับไม่ได้](#-สิ่งที่เทสต์-1979-เคสจับไม่ได้)
+> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 1985 เคสจับไม่ได้](#-สิ่งที่เทสต์-1985-เคสจับไม่ได้)
> และรันซ้ำเองได้ด้วย `npm run check:ai`
| ไฟล์ | เคส | ทดสอบอะไร |
@@ -3677,7 +3683,7 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด
| `sheetNames.test.ts` | 7 | กติกาชื่อชีตของ Excel (#54): ทุกข้อที่ผิด, ตัวพิมพ์เล็กใหญ่ถือเป็นชื่อเดียว, แก้ชื่อให้ถูกและหาชื่อที่ว่าง, ปุ่ม + ไม่ได้ชื่อซ้ำหลังลบแท็บ, งานเก่าที่ชื่อซ้ำ: แท็บแรกได้ชื่อเดิมและสูตรไม่ย้าย, ชื่อผิดกติกาเปลี่ยนพร้อมสูตร |
| `sheetMerges.test.ts` | 35 | ช่องไหนถูก merge กลืน, การเลื่อน merge ตอนแทรก/ลบแถว-คอลัมน์, merge ที่เหลือช่องเดียวถูกทิ้ง |
| `sheetTemplate.test.ts` | 14 | เซลล์ไหนล็อก/กรอกได้, ตัวเลือก dropdown แบบ inline และแบบอ้างช่วง, แปลงความกว้างคอลัมน์ |
-| `fileLosses.test.ts` | 12 | ไฟล์ที่เปิดมามีอะไรที่เก็บไว้ไม่ได้ (#83): ไฟล์จริงที่มีรูป กราฟ และฟังก์ชันที่เอนจินไม่มี นับได้ครบพร้อมชื่อชีต, สูตรนั้นส่งออกไปเหมือนเดิม, ไฟล์ธรรมดาไม่มีรายงาน, `_xlfn.` ของฟังก์ชันที่มีไม่ถูกนับ, Pivot Table/รูปร่าง/มาโคร/ลิงก์ และ chart sheet ตามรูปแบบใน spec, ไฟล์ที่ไม่ใช่ zip ไม่ทำให้การเปิดล้ม, ชื่อช่วงที่นำเข้าไม่ได้ (#60) เป็นบรรทัดหนึ่งของรายงานเดียวกัน ต่อจากฟังก์ชันที่ไม่มี เป็นรายงานทั้งหมดของไฟล์ที่ไม่มีอย่างอื่น และยังบอกแม้อ่าน package ซ้ำไม่ได้, เซลล์ error คงที่ (#227) นับพร้อมชื่อชีตแต่สูตรที่ผลเป็น error ไม่นับ และยังนับได้หลังส่งออกแล้วเปิดใหม่ |
+| `fileLosses.test.ts` | 11 | ไฟล์ที่เปิดมามีอะไรที่เก็บไว้ไม่ได้ (#83): ไฟล์จริงที่มีรูป กราฟ และฟังก์ชันที่เอนจินไม่มี นับได้ครบพร้อมชื่อชีต, สูตรนั้นส่งออกไปเหมือนเดิม, ไฟล์ธรรมดาไม่มีรายงาน, `_xlfn.` ของฟังก์ชันที่มีไม่ถูกนับ, Pivot Table/รูปร่าง/มาโคร/ลิงก์ และ chart sheet ตามรูปแบบใน spec, ไฟล์ที่ไม่ใช่ zip ไม่ทำให้การเปิดล้ม, ชื่อช่วงที่นำเข้าไม่ได้ (#60) เป็นบรรทัดหนึ่งของรายงานเดียวกัน ต่อจากฟังก์ชันที่ไม่มี เป็นรายงานทั้งหมดของไฟล์ที่ไม่มีอย่างอื่น และยังบอกแม้อ่าน package ซ้ำไม่ได้, เซลล์ error คงที่ (#227) ไม่อยู่ในรายงานแล้วเพราะแอปอ่านเป็น error จริงตั้งแต่ #226 |
| `excelIO.test.ts` | 58 | สร้างไฟล์ .xlsx จริงแล้ววนครบรอบ: อ่านช่องกรอก/dropdown/ความกว้าง, ไฟล์ที่ไม่ protect ไม่ใช่แม่แบบ, export แล้ว import กลับได้เหมือนเดิม, และรูปแบบ (แถบสี/ขนาดตัวอักษร/เส้นขอบ/ความสูงแถว/merge) ครบรอบ รวมถึงกฎจัดรูปแบบตามเงื่อนไขทั้ง 5 แบบ และโน้ตของเซลล์ (ทั้งที่เขียนเป็นข้อความธรรมดาและ rich text ของ Excel), dropdown ข้ามชีตอ่านจากชีตที่กฎชี้ (ชื่อชีตมีเครื่องหมายคำพูด, ชีตซ่อน, ชีตที่ไม่มีอยู่, แม่แบบร่าง 0 ของ PaynEat ERP), คอลัมน์กว้าง 9 พอดีส่งออกแล้วยังเป็น 9 (#136), merge ในไฟล์มีค่าที่ซ้ายบนช่องเดียว SUM/COUNTA ตรงกับ Excel และ merge ยังส่งออกไป (#55), เซลล์ `t="e"` จริงเปิดมาได้รหัส error ไม่ใช่ช่องว่าง ส่งออกกลับเป็น `t="e"` และเปิดซ้ำยังอยู่ ส่วน `'#N/A` กับช่องรูปแบบข้อความยังออกเป็นข้อความ (#227) |
| `charts.test.ts` | 42 | อ่านช่วงเป็นชุดข้อมูล+ป้ายกำกับ (รวมกรณีคอลัมน์ซ้ายเป็นข้อความ), ช่องว่างเมื่อไม่ใช่ตัวเลข, แกนที่รวมศูนย์, การย้าย/ย่อขยาย/หนีบกรอบไว้ในชีต, คำอธิบายสีตามชนิดกราฟ, การเลื่อนตามแทรก/ลบ |
| `server/urlGuard.test.ts` | 31 | ที่อยู่ที่ห้ามยิงถึง (loopback, วงใน, metadata ของคลาวด์, IPv6 link-local), IPv4 ที่ซ่อนใน IPv6 (`::ffff:`, `::`, SIIT, NAT64, 6to4, Teredo), scheme ที่ไม่ใช่ http, และ allowlist |
@@ -3700,6 +3706,7 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด
| `store/datePivot.test.ts` | 1 | pivot ตามวัน (#45): หัวแถวเป็นวันที่ ไม่ใช่เลข serial |
| `store/pivotSize.test.ts` | 6 | ชีตสรุปใหญ่เท่าผลลัพธ์ (#56): 35 สินค้าครบพร้อมแถวรวมตัวหนา, 12 เดือนครบพร้อมคอลัมน์รวม, สรุปใหม่ก็ได้ครบ, ผลเล็กยังได้ชีต 30×10 ที่มีที่ทำงาน, ผลที่ใหญ่เกินชีตไม่สร้างแต่บอกขนาด และสรุปใหม่ที่ใหญ่เกินเตือนโดย pivot เดิมไม่ถูกทับ |
| `store/pivotError.test.ts` | 2 | pivot ที่แหล่งข้อมูลมี error (#222): ผลรวม เฉลี่ย ต่ำสุด สูงสุด ของกลุ่มที่มี `#DIV/0!` และแถวรวมขึ้น `#DIV/0!` กลุ่มอื่นได้ตัวเลขเดิม ทั้งตอนสร้างและตอนสรุปใหม่หลังแหล่งข้อมูลมี error |
+| `store/errorLiterals.test.ts` | 4 | รหัส error ที่อยู่ในเซลล์เป็นค่าคือ error จริง (#226) ทุกทางเข้า: พิมพ์ (`SUM` ได้ `#DIV/0!` · `IFERROR`/`IFNA` จับได้), วางแบบที่ Excel ก๊อป (`#1`, `#N/A please`, `'#N/A` ยังเป็นข้อความ), เปิดจากไฟล์ (เซลล์ `t="e"` เป็น error แต่ string cell ที่เป็น `#N/A` ยังเป็นข้อความ) และ `SUM`/`IFERROR` ที่อ่านชีต pivot ที่มี error |
| `store/dateEntry.test.ts` | 5 | พิมพ์วันที่แบบคน (#45): `=TODAY()`/`=DATE()`/`=NOW()` ได้รูปแบบวันที่เองถ้าเซลล์ยังเป็น "ทั่วไป", วันที่และเวลาขยายคอลัมน์ที่ยังกว้างเท่าเดิม, เลือกรูปแบบวันที่แทนรูปแบบของไฟล์ |
| `store/convertDates.test.ts` | 5 | "แปลงเป็นวันที่" และ "วันที่ (พ.ศ.)" ผ่าน store (#82): แปลงแล้วแสดงปี พ.ศ. ช่องอ่านไม่ออกและสูตรไม่ถูกแตะ, ค.ศ. เดือนก่อน, undo ครั้งเดียวกลับทั้งหมด, รูปแบบที่เลือกไว้ไม่ถูกทับ, รูปแบบ พ.ศ. แสดงและกลับเป็นวันที่ปกติได้ |
| `store/sampleNotice.test.ts` | 19 | เปิดมาเป็นตารางว่าง, ข้อมูลตัวอย่างเปิดด้วยปุ่มและ undo ได้, ตัวอย่างที่ค้างในเบราว์เซอร์ (ทั้งสองภาษา) ไม่ถูกคืน แต่แก้แล้วแม้จุดเดียวถือเป็นงาน, ไฟล์ใหม่ undo ได้และประกาศออกเสียง, แถบตัวอย่างอยู่เฉพาะตอนที่ยังไม่แตะ และตัวอย่างตามภาษาบนจอ |
diff --git a/public/screenshots/27-landing-stats.png b/public/screenshots/27-landing-stats.png
index 71229d0c..c26492de 100644
Binary files a/public/screenshots/27-landing-stats.png and b/public/screenshots/27-landing-stats.png differ
diff --git a/public/screenshots/en/27-landing-stats.png b/public/screenshots/en/27-landing-stats.png
index 034f2cd5..7472ac29 100644
Binary files a/public/screenshots/en/27-landing-stats.png and b/public/screenshots/en/27-landing-stats.png differ
diff --git a/public/screenshots/screenshots.json b/public/screenshots/screenshots.json
index 45195289..0f8adb4b 100644
--- a/public/screenshots/screenshots.json
+++ b/public/screenshots/screenshots.json
@@ -5,7 +5,7 @@
"27-landing-stats.png": {
"paletteFormulas": 37,
"engineFunctions": 66,
- "tests": 1979,
+ "tests": 1985,
"securityTests": 325
},
"38-landing-problems.png": {
@@ -21,7 +21,7 @@
"en/27-landing-stats.png": {
"paletteFormulas": 37,
"engineFunctions": 66,
- "tests": 1979,
+ "tests": 1985,
"securityTests": 325
},
"en/38-landing-problems.png": {
diff --git a/public/social-preview.png b/public/social-preview.png
index a610bf7b..dff2e948 100644
Binary files a/public/social-preview.png and b/public/social-preview.png differ
diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx
index 324440d4..0c195a66 100644
--- a/src/app/opengraph-image.tsx
+++ b/src/app/opengraph-image.tsx
@@ -29,7 +29,7 @@ export default function OgImage() {
const stats = [
["66", "engine functions"],
["37", "palette formulas"],
- ["1979", "automated tests"],
+ ["1985", "automated tests"],
["325", "of them security"],
["0", "formula libraries"],
];
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index d9393d48..74059dc4 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -431,7 +431,7 @@ export const en: Messages = {
stats: [
{ value: "37", label: "ready-made formulas" },
{ value: "66", label: "engine functions" },
- { value: "1979", label: "automated tests" },
+ { value: "1985", label: "automated tests" },
{ value: "325", label: "security tests" },
{ value: "0", label: "formula libraries used" },
],
@@ -818,15 +818,12 @@ export const en: Messages = {
pivots: (n) => `${n} Excel ${n === 1 ? "PivotTable" : "PivotTables"}`,
unknownFunctions: (n, names) => `${n} ${n === 1 ? "formula uses" : "formulas use"} a function the app doesn't have yet (${names.join(", ")})`,
names: (n, names) => `${n} range ${n === 1 ? "name" : "names"} that could not be brought in (${names.join(", ")})`,
- errorValues: (n) => `${n} ${n === 1 ? "cell holds" : "cells hold"} an error value such as #DIV/0! or #N/A`,
externalLinks: (n) => `${n} ${n === 1 ? "link" : "links"} to other files`,
macros: () => "Macros (VBA)",
},
detail: {
unknownFunctions: "The app shows #NAME? for them, but the exported file keeps the formula as it was, and Excel can calculate it",
names: "Formulas using them read #NAME?, and the exported file won't have these names. Only a name for one range on a sheet of the file comes in — not a formula, a whole column or several areas",
- errorValues:
- "The app shows them as text, so a formula that reads one does not see an error: SUM steps over it and gives a number. The exported file writes them back as error values, as they were",
externalLinks: "Values pulled from other files won't update in the app",
macros: "The app doesn't run macros, and the exported file doesn't carry them",
},
diff --git a/src/i18n/th.ts b/src/i18n/th.ts
index 7e95516b..fb633012 100644
--- a/src/i18n/th.ts
+++ b/src/i18n/th.ts
@@ -431,7 +431,7 @@ export const th: Messages = {
stats: [
{ value: "37", label: "สูตรพร้อมใช้" },
{ value: "66", label: "ฟังก์ชันในเอนจิน" },
- { value: "1979", label: "เทสต์อัตโนมัติ" },
+ { value: "1985", label: "เทสต์อัตโนมัติ" },
{ value: "325", label: "เทสต์ด้านความปลอดภัย" },
{ value: "0", label: "ไลบรารีคำนวณสูตร" },
],
@@ -817,14 +817,12 @@ export const th: Messages = {
pivots: (n) => `Pivot Table ของ Excel ${n} ตัว`,
unknownFunctions: (n, names) => `สูตร ${n} ช่องที่ใช้ฟังก์ชันที่แอปยังไม่มี (${names.join(", ")})`,
names: (n, names) => `ชื่อช่วง ${n} ชื่อที่นำเข้ามาไม่ได้ (${names.join(", ")})`,
- errorValues: (n) => `เซลล์ ${n} ช่องที่เก็บค่า error เช่น #DIV/0! หรือ #N/A`,
externalLinks: (n) => `ลิงก์ไปไฟล์อื่น ${n} ลิงก์`,
macros: () => "มาโคร (VBA)",
},
detail: {
unknownFunctions: "ในแอปจะขึ้น #NAME? แต่ไฟล์ที่ส่งออกยังเก็บสูตรเดิมไว้ เปิดใน Excel แล้วคำนวณได้",
names: "สูตรที่ใช้ชื่อเหล่านี้ขึ้น #NAME? และไฟล์ที่ส่งออกจะไม่มีชื่อเหล่านี้ · นำเข้าได้เฉพาะชื่อที่ชี้ช่วงเดียวบนชีตในไฟล์ ไม่ใช่สูตร ทั้งคอลัมน์ หรือหลายช่วง",
- errorValues: "ในแอปแสดงเป็นข้อความ สูตรที่อ่านช่องเหล่านี้จึงไม่เห็นว่าเป็น error เช่น SUM จะข้ามไปแล้วได้ตัวเลข · ไฟล์ที่ส่งออกยังเขียนกลับเป็นค่า error เหมือนเดิม",
externalLinks: "ค่าที่ดึงจากไฟล์อื่นจะไม่อัปเดตในแอป",
macros: "แอปไม่รันมาโคร และไฟล์ที่ส่งออกไม่มีมาโครติดไป",
},
diff --git a/src/lib/cellLiteral.test.ts b/src/lib/cellLiteral.test.ts
index 1a09a8de..e94697ec 100644
--- a/src/lib/cellLiteral.test.ts
+++ b/src/lib/cellLiteral.test.ts
@@ -3,7 +3,8 @@
import ExcelJS from "exceljs";
import { beforeEach, describe, expect, it } from "vitest";
-import { formattedNumber, literalValue, looksNumeric, rawForText } from "./cellLiteral";
+import { ERROR_CODES, formattedNumber, literalValue, looksNumeric, rawForText } from "./cellLiteral";
+import { FormulaError } from "./formulaEngine/types";
import { computeSheet, createEmptySheet, setCellRaw, setRangeFormat, SheetModel } from "./sheet";
import { computeStats, resetComputeCache } from "./sheetCompute";
import { sortRange } from "./sheetSort";
@@ -187,6 +188,36 @@ describe("rawForText", () => {
});
});
+describe("an error code in a cell is that error (#226)", () => {
+ it("for each of the seven codes, spelled as Excel spells them", () => {
+ for (const code of ERROR_CODES) {
+ const value = literalValue(code);
+ expect(value, code).toBeInstanceOf(FormulaError);
+ expect((value as FormulaError).code, code).toBe(code);
+ }
+ });
+
+ it("in any case, spelled back as Excel spells it, as a typed #n/a is in Excel", () => {
+ for (const typed of ["#n/a", "#div/0!", "#Value!", "#ref!"]) {
+ const value = literalValue(typed);
+ expect(value, typed).toBeInstanceOf(FormulaError);
+ expect((value as FormulaError).code, typed).toBe(typed.toUpperCase());
+ }
+ // A file's string cell reading "#n/a" is text there too, so it keeps an apostrophe.
+ expect(rawForText("#n/a")).toBe("'#n/a");
+ });
+
+ it("but stays text after an apostrophe, under the Text format, or when it only starts with #", () => {
+ expect(literalValue("'#N/A")).toBe("#N/A");
+ expect(literalValue("#DIV/0!", "text")).toBe("#DIV/0!");
+ // `#SPILL!` and the other newer codes stay text when typed into Excel too.
+ for (const t of ["#1", "#N/A please", "#hashtag", "#", "#SPILL!", "#CALC!"]) expect(literalValue(t), t).toBe(t);
+ // Text from a file's string cell keeps its apostrophe, so it does not turn into the error.
+ expect(rawForText("#N/A")).toBe("'#N/A");
+ expect(literalValue(rawForText("#DIV/0!"))).toBe("#DIV/0!");
+ });
+});
+
describe("the .xlsx round trip", () => {
async function exported(column: string[]): Promise {
const sheet = sheetWith(column);
diff --git a/src/lib/cellLiteral.ts b/src/lib/cellLiteral.ts
index 99bba82f..0416ccf2 100644
--- a/src/lib/cellLiteral.ts
+++ b/src/lib/cellLiteral.ts
@@ -3,7 +3,7 @@
import type { NumberFormat } from "./cellFormat";
import { dateLiteral } from "./excelDate";
-import type { FormulaValue } from "./formulaEngine/types";
+import { FormulaError, type FormulaValue } from "./formulaEngine/types";
/**
* What a non-formula cell holds: a number when it reads as one, its text otherwise.
@@ -33,7 +33,13 @@ import type { FormulaValue } from "./formulaEngine/types";
* a pasted column of them used to add up to 0. The text stays in the cell and shows as typed; the
* file gets the number with a matching format (`formattedNumber`). Commas in the wrong places
* (`1,25`), dashes (`081-234-5678`, `123-4-56789-0`) and a leading zero (`01,250`) stay text.
- * 7. Anything else that `Number()` accepts is a number.
+ * 7. **One of Excel's seven error codes** (`#N/A`, `#DIV/0!`, …) is that error (#226), as when it is
+ * typed into Excel: `SUM` over it gives the error and `IFERROR` catches it. It used to be text,
+ * so a pasted or imported `#DIV/0!` was stepped over and the total came out clean. Case does not
+ * matter, as in Excel, where a typed `#n/a` becomes `#N/A`. Text that only starts with `#`
+ * (`#1`, `#N/A please`) is still text, and so are the newer codes (`#SPILL!`), which Excel
+ * itself keeps as text when typed.
+ * 8. Anything else that `Number()` accepts is a number.
*
* Nothing is stored to make the automatic rules work, so a sheet saved before they existed shows
* its zeros again the moment it is opened.
@@ -42,6 +48,8 @@ export function literalValue(raw: string, numberFormat?: NumberFormat): FormulaV
if (raw === "") return null;
if (raw.startsWith("'")) return raw.slice(1);
if (numberFormat === "text") return raw;
+ const error = errorLiteral(raw);
+ if (error) return error;
if (LEADING_ZERO.test(raw) || LONG_DIGITS.test(raw)) return raw;
const date = dateLiteral(raw);
if (date) return date.serial;
@@ -53,6 +61,16 @@ export function literalValue(raw: string, numberFormat?: NumberFormat): FormulaV
const LEADING_ZERO = /^0\d+$/;
+/** The error values a cell can hold as a constant, the seven Excel writes into a file as `t="e"`. */
+export const ERROR_CODES: readonly string[] = ["#NULL!", "#DIV/0!", "#VALUE!", "#REF!", "#NAME?", "#NUM!", "#N/A"];
+
+/** The error a cell's text stands for, spelled as Excel spells it, or null when the text is not one
+ * of `ERROR_CODES`. Case is ignored, as Excel ignores it when the code is typed. */
+export function errorLiteral(raw: string): FormulaError | null {
+ const code = ERROR_CODES.find((c) => c === raw.toUpperCase());
+ return code ? new FormulaError(code) : null;
+}
+
/**
* A number with thousands commas, a trailing percent or a leading currency sign: sign, then
* currency, then digits grouped in threes, then decimals, then `%`. The sign may also sit after the
diff --git a/src/lib/dataSources/jsonToTable.ts b/src/lib/dataSources/jsonToTable.ts
index 79467cbb..324b0137 100644
--- a/src/lib/dataSources/jsonToTable.ts
+++ b/src/lib/dataSources/jsonToTable.ts
@@ -193,7 +193,8 @@ export function parseCsv(text: string): string[][] {
/**
* A CSV field is text until it reads as a number — by the grid's own rule (#36), not `Number()`, so
* a branch code `007` or a phone number keeps its zeros instead of arriving as `7`. `literalValue`
- * imports only types, so the server's copy of this file pulls in nothing from the browser side.
+ * pulls in nothing from the browser side, so neither does the server's copy of this file. An error
+ * code (#226) comes back as its text here; the cell it lands in reads it as the error.
*/
function coerce(s: string): CellValue {
const t = s.trim();
diff --git a/src/lib/excelIO.test.ts b/src/lib/excelIO.test.ts
index f4a6f976..be84f61b 100644
--- a/src/lib/excelIO.test.ts
+++ b/src/lib/excelIO.test.ts
@@ -762,7 +762,7 @@ describe("a constant error cell in an opened file (#227)", () => {
expect(ws.getCell("A2").value).toBe("#N/A");
// Typed as Excel spells it, it is the error Excel would make of it.
expect(ws.getCell("A3").value).toEqual({ error: "#REF!" });
- // Only the exact spelling Excel writes in a file goes out as an error; any other spelling stays as typed.
- expect(ws.getCell("A4").value).toBe("#n/a");
+ // Excel makes the error of a typed #n/a too, so it goes out as #N/A (checked in Excel, #226).
+ expect(ws.getCell("A4").value).toEqual({ error: "#N/A" });
});
});
diff --git a/src/lib/excelIO.ts b/src/lib/excelIO.ts
index 21597980..aab5e093 100644
--- a/src/lib/excelIO.ts
+++ b/src/lib/excelIO.ts
@@ -700,9 +700,6 @@ export async function importWorkbookFromFile(file: File): Promise v !== undefined && v !== false);
@@ -730,15 +727,13 @@ async function writeSheetToWorksheet(worksheet: ExcelJS.Worksheet, sheet: SheetM
};
} else if (raw === "") {
// leave blank
- } else if (EXCEL_ERROR_CODES.has(raw) && format?.numberFormat !== "text") {
- // An error code as Excel spells it goes out as the error value, the way Excel itself stores
- // `#N/A` typed into a cell. Written as text, a file that held `#DIV/0!` came back to Excel
- // with SUM stepping over it (#227). An apostrophe or a Text format still keeps it text.
- cell.value = { error: raw } as ExcelJS.CellErrorValue;
} else {
// The engine's own reading, not a second copy of it: a file that decided differently from
- // the grid is how `0812345678` went out as the number 812345678 (#23).
- cell.value = literalValue(raw, sheet.formats[r]?.[c]?.numberFormat) as string | number;
+ // the grid is how `0812345678` went out as the number 812345678 (#23). An error code goes
+ // out as the error value, as Excel stores `#N/A` typed into a cell (#227, #226); an
+ // apostrophe or a Text format still keeps it text.
+ const value = literalValue(raw, format?.numberFormat);
+ cell.value = isError(value) ? ({ error: value.code } as ExcelJS.CellErrorValue) : (value as string | number);
}
if (format?.bold || format?.color || format?.fontSize || format?.italic || format?.underline) {
diff --git a/src/lib/fileLosses.test.ts b/src/lib/fileLosses.test.ts
index 06de1bbd..d49ef3f0 100644
--- a/src/lib/fileLosses.test.ts
+++ b/src/lib/fileLosses.test.ts
@@ -158,7 +158,7 @@ describe("range names a file holds that cannot come in (#60), in the same report
});
});
-describe("constant error cells (#227), shown as text until #226", () => {
+describe("constant error cells (#227, #226)", () => {
/** Two sheets: errors typed into the first, as Excel writes them, and a formula that errs in the second. */
async function errorsFile(): Promise {
const wb = new ExcelJS.Workbook();
@@ -172,15 +172,9 @@ describe("constant error cells (#227), shown as text until #226", () => {
return new Uint8Array(await wb.xlsx.writeBuffer());
}
- it("are counted and placed; a formula whose result is an error is not, since it calculates again", async () => {
+ it("are not reported: the app now reads them as the errors they are, and export keeps them", async () => {
+ // #227 listed them because the app held them as text; since #226 nothing about them is lost.
const { losses } = await openFixture(await errorsFile());
- expect(losses).toEqual([{ kind: "errorValues", count: 2, sheets: ["ยอดขาย"] }]);
- });
-
- it("are still there, and still said, after the file goes out of the app and back in", async () => {
- const { sheets } = await openFixture(await errorsFile());
- const blob = await exportWorkbookToXlsxBlob(sheets.map(({ name, sheet }) => ({ name, sheet, computed: computeSheet(sheet) })));
- const { losses } = await openFixture(new Uint8Array(await blob.arrayBuffer()));
- expect(losses).toEqual([{ kind: "errorValues", count: 2, sheets: ["ยอดขาย"] }]);
+ expect(losses).toEqual([]);
});
});
diff --git a/src/lib/fileLosses.ts b/src/lib/fileLosses.ts
index d408e2ce..9ccd81b7 100644
--- a/src/lib/fileLosses.ts
+++ b/src/lib/fileLosses.ts
@@ -22,16 +22,7 @@ import { tokenize } from "./formulaEngine/tokenizer";
import { FUNCTIONS } from "./formulaEngine/functions";
import type { SheetModel } from "./sheet";
-export type LossKind =
- | "pictures"
- | "shapes"
- | "charts"
- | "pivots"
- | "macros"
- | "externalLinks"
- | "unknownFunctions"
- | "names"
- | "errorValues";
+export type LossKind = "pictures" | "shapes" | "charts" | "pivots" | "macros" | "externalLinks" | "unknownFunctions" | "names";
export interface FileLoss {
kind: LossKind;
@@ -45,10 +36,9 @@ export interface FileLoss {
/**
* The order the notice lists them in: what a person sees in the file first, then what runs. Range
* names sit next to unknown functions because both end in `#NAME?` on screen; a person who
- * reads one line about `#NAME?` should find the other cause right under it. Error values follow,
- * the last thing that changes what a formula reads.
+ * reads one line about `#NAME?` should find the other cause right under it.
*/
-const ORDER: LossKind[] = ["pictures", "charts", "shapes", "pivots", "unknownFunctions", "names", "errorValues", "externalLinks", "macros"];
+const ORDER: LossKind[] = ["pictures", "charts", "shapes", "pivots", "unknownFunctions", "names", "externalLinks", "macros"];
interface Rel {
type: string;
@@ -88,23 +78,6 @@ function resolve(dir: string, target: string): string {
const endsWith = (type: string, kind: string) => type.endsWith(`/${kind}`);
-/**
- * Cells holding a constant error, `#DIV/0!` (#227). The app keeps the code as
- * text until #226, so a formula that reads one does not see an error; that is what the notice owes.
- * A cell with a formula is not one of them: its error is a result, and the app calculates it again.
- */
-export function countConstantErrors(sheetXml: string): number {
- if (!sheetXml.includes('t="e"')) return 0;
- let n = 0;
- for (const m of sheetXml.matchAll(/<(?:[A-Za-z_][\w.-]*:)?c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/(?:[A-Za-z_][\w.-]*:)?c>)/g)) {
- if (!/\bt="e"/.test(m[1])) continue;
- const inner = m[2] ?? "";
- if (/<(?:[A-Za-z_][\w.-]*:)?f[\s>/]/.test(inner)) continue;
- if (/<(?:[A-Za-z_][\w.-]*:)?v[\s>]/.test(inner)) n += 1;
- }
- return n;
-}
-
/** Count of elements with this local name, whatever prefix the writer chose (`xdr:pic`, `pic`). */
function countElements(xml: string, local: string): number {
return (xml.match(new RegExp(`<(?:[A-Za-z_][\\w.-]*:)?${local}[\\s>/]`, "g")) ?? []).length;
@@ -189,8 +162,6 @@ export async function findFileLosses(
tally.add("charts", 1, part.name);
continue;
}
- const sheetXml = (await zip.file(part.path)?.async("string")) ?? "";
- tally.add("errorValues", countConstantErrors(sheetXml), part.name);
const rels = await relsOf(zip, part.path);
for (const rel of rels.values()) {
if (endsWith(rel.type, "pivotTable")) tally.add("pivots", 1, part.name);
diff --git a/src/store/errorLiterals.test.ts b/src/store/errorLiterals.test.ts
new file mode 100644
index 00000000..4075f96b
--- /dev/null
+++ b/src/store/errorLiterals.test.ts
@@ -0,0 +1,93 @@
+// Copyright 2026 Suruch Chakrapeesirisuk
+// SPDX-License-Identifier: Apache-2.0
+
+import ExcelJS from "exceljs";
+import { beforeEach, describe, expect, it } from "vitest";
+import { importWorkbookFromFile } from "@/lib/excelIO";
+import { FormulaError, type FormulaValue } from "@/lib/formulaEngine/types";
+import { computeSheet, sheetFromGrid } from "@/lib/sheet";
+import { computeTab, useSheetStore } from "./sheetStore";
+
+/**
+ * An error code that reaches a cell as a value (#226). It used to be text, so `SUM` stepped over a
+ * `#DIV/0!` and `IFERROR` let it through, and the total came out clean. Each way in is here: typing,
+ * pasting, opening a file, and a pivot writing a group's error.
+ */
+const state = () => useSheetStore.getState();
+const active = () => state().sheets.find((t) => t.id === state().activeSheetId)!;
+const values = () => computeTab(active().sheet, state().sheets).values;
+const codeOf = (v: FormulaValue) => (v instanceof FormulaError ? v.code : v);
+
+beforeEach(() => state().startBlank());
+
+describe("an error code in a cell reads as the error (#226)", () => {
+ it("when typed: SUM gives the error, IFERROR and IFNA catch it", () => {
+ state().setCellRaw(0, 0, "100");
+ state().setCellRaw(1, 0, "#DIV/0!");
+ state().setCellRaw(2, 0, "80");
+ state().setCellRaw(3, 0, "#N/A");
+ state().setCellRaw(0, 1, "=SUM(A1:A3)");
+ state().setCellRaw(1, 1, "=IFERROR(A2,0)");
+ state().setCellRaw(2, 1, '=IFNA(A4,"none")');
+ state().setCellRaw(3, 1, "=A2*2");
+ const v = values();
+ expect(codeOf(v[1][0])).toBe("#DIV/0!");
+ expect(codeOf(v[0][1])).toBe("#DIV/0!");
+ expect(v[1][1]).toBe(0);
+ expect(v[2][1]).toBe("none");
+ expect(codeOf(v[3][1])).toBe("#DIV/0!");
+ });
+
+ it("when pasted, as Excel copies an error cell; text that only starts with # stays text", () => {
+ state().setSelection({ startRow: 0, startCol: 0, endRow: 0, endCol: 0, anchorRow: 0, anchorCol: 0 });
+ state().pasteAtSelection("100\t#1\r\n#DIV/0!\t#N/A please\r\n80\t'#N/A\r\n");
+ state().setCellRaw(3, 0, "=SUM(A1:A3)");
+ const v = values();
+ expect(codeOf(v[3][0])).toBe("#DIV/0!");
+ expect(v.slice(0, 3).map((r) => r[1])).toEqual(["#1", "#N/A please", "#N/A"]);
+ });
+
+ it("when opened from a file that holds it as an error cell", async () => {
+ const wb = new ExcelJS.Workbook();
+ const ws = wb.addWorksheet("ยอดขาย");
+ ws.getCell("A1").value = { error: "#DIV/0!" } as ExcelJS.CellErrorValue;
+ ws.getCell("A2").value = { error: "#N/A" } as ExcelJS.CellErrorValue;
+ ws.getCell("A3").value = 5;
+ ws.getCell("A4").value = { formula: "SUM(A1:A3)", result: { error: "#DIV/0!" } } as ExcelJS.CellFormulaValue;
+ // A string cell that happens to read "#N/A" is text in Excel, and stays text here.
+ ws.getCell("B1").value = "#N/A";
+ const [{ sheet }] = await importWorkbookFromFile(new File([await wb.xlsx.writeBuffer()], "errors.xlsx"));
+ const v = computeSheet(sheet).values;
+ expect(codeOf(v[0][0])).toBe("#DIV/0!");
+ expect(codeOf(v[1][0])).toBe("#N/A");
+ expect(codeOf(v[3][0])).toBe("#DIV/0!");
+ expect(v[0][1]).toBe("#N/A");
+ });
+
+ it("when a pivot writes a group's error, so a total over the pivot sheet is not clean either", () => {
+ useSheetStore.setState((s) => ({
+ sheets: s.sheets.map((t, i) =>
+ i === 0
+ ? {
+ ...t,
+ sheet: sheetFromGrid([
+ ["Region", "Amount"],
+ ["North", "100"],
+ ["North", "=1/0"],
+ ["South", "80"],
+ ]),
+ }
+ : t
+ ),
+ }));
+ state().setSelection({ startRow: 0, startCol: 0, endRow: 3, endCol: 1, anchorRow: 0, anchorCol: 0 });
+ expect(state().buildPivotSheet({ rowFields: [0], colField: null, valueField: 1, agg: "sum" })).toBe(true);
+ const pivotName = state().sheets[state().sheets.length - 1].name;
+ state().setActiveSheet(state().sheets[0].id);
+ state().setCellRaw(5, 0, `=SUM('${pivotName}'!B2:B3)`);
+ state().setCellRaw(6, 0, `=IFERROR('${pivotName}'!B2,0)`);
+ const v = values();
+ expect(codeOf(v[5][0])).toBe("#DIV/0!");
+ expect(v[6][0]).toBe(0);
+ });
+});