diff --git a/README.en.md b/README.en.md index 26c8c44..2c5ebd5 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 Tailwind CSS Zustand Open the app - Vitest + Vitest CI

@@ -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), 1974 automated tests. +an undo that does not erase the other person's work. Bilingual UI (Thai/English), 1979 automated tests. --- @@ -109,7 +109,7 @@ Want the harder parts: [embedding a Thai font in the PDF, with stacked tone mark --- -### 🧪 What 1974 passing tests could not catch +### 🧪 What 1979 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 — 1974 tests, `axe` clean on +**And it happened again, in a different place.** With every gate green — 1979 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 1974-case Vitest suite | +| `npm test` | Run the 1979-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) | @@ -2512,12 +2512,16 @@ 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 |

- **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. - **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 @@ -3010,7 +3014,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 (1974 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 (1979 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 @@ -3284,7 +3288,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), 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, constant error values), 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, @@ -4050,13 +4054,13 @@ the framework bundle itself, which isn't a trade worth making here. Written down ## 🧪 Testing ```bash -npm test # 1974 cases across 140 files, via Vitest +npm test # 1979 cases across 140 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 1974 cases opens the app**, and nearly every bug this project found by hand lived in +**But not one of those 1979 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` @@ -4175,10 +4179,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.) -> **1974 tests passed, and 43% of the assistant's answers were unusable** — because those tests mock +> **1979 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 1974 passing tests could not catch](#-what-1974-passing-tests-could-not-catch), repeatable +> [What 1979 passing tests could not catch](#-what-1979-passing-tests-could-not-catch), repeatable > with `npm run check:ai`. | File | Cases | Tests | @@ -4219,8 +4223,8 @@ 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` | 10 | 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 | -| `excelIO.test.ts` | 55 | 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) | +| `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 | +| `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 | | `server/sourcesAuth.test.ts` | 9 | No token means off, right/wrong/prefix tokens, and telling "switched off" apart from "wrong token" | diff --git a/README.md b/README.md index 7da7c4b..2791b9b 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Tailwind CSS Zustand เปิดแอป - Vitest + Vitest CI

@@ -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), 1974 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง +(Thai/English), 1979 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง --- @@ -106,7 +106,7 @@ top/bottom ranks, colour scales and data bars — and round-trips through Excel' --- -### 🧪 สิ่งที่เทสต์ 1974 เคสจับไม่ได้ +### 🧪 สิ่งที่เทสต์ 1979 เคสจับไม่ได้ เทสต์ของผู้ช่วย 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 ที่มองเห็น เป็นตัวเลขผิดที่มองไม่เห็น** -**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 1974 เทสต์ผ่าน, `axe` ไม่มี violation ทั้งสองหน้า +**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 1979 เทสต์ผ่าน, `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 1974 เคสด้วย Vitest | +| `npm test` | รัน unit test 1979 เคสด้วย Vitest | | `npm run check:readme` | ตรวจว่า README ยังตรงกับโค้ด (ลิงก์/ภาพ/จำนวนเทสต์/โมดูลใหม่/สองภาษาตรงกัน) | | `npm run check:screens` | ตัวเลขที่พิมพ์อยู่บนภาพหน้าจอยังตรงกับซอร์สไหม | | `npm run check:rls` | ยิงจริงสองบัญชีบน Supabase ของคุณ ว่าฐานข้อมูลปฏิเสธตามที่ policy เขียนไว้ไหม (ต้องตั้ง env) | @@ -2203,12 +2203,16 @@ 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 เก็บ + ถ้าขึ้นต้นด้วย `'` หรือจัดรูปแบบเป็นข้อความจะยังเป็นข้อความ - กด **เข้าใจแล้ว** เพื่อปิด แล้วเปิดดูอีกครั้งได้จาก**เมนู** (หรือปุ่มสีเหลืองข้างปุ่มส่งออก Excel ตั้งแต่ 1366px) ตราบเท่าที่ชีตจากไฟล์นั้นยังเปิดอยู่ · undo การเปิดไฟล์ หรือเปิดไฟล์อื่นทับ รายงานก็หายไปด้วย - อ่านจาก package ต้นฉบับ ไม่ใช่จากผลของตัวนำเข้า เพราะผลของตัวนำเข้าคือสิ่งที่ของพวกนี้หายไปแล้ว · @@ -2620,7 +2624,7 @@ stack ของ `font-mono` จึงต่อท้ายด้วย Plex Sans | `@anthropic-ai/sdk` | เชื่อมต่อ Claude API สำหรับผู้ช่วย AI | | `lucide-react` | ไอคอน UI | | `clsx` | รวม className แบบมีเงื่อนไข | -| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (1974 เคส) | +| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (1979 เคส) | > **หมายเหตุ:** ไม่ได้ใช้ไลบรารีคำนวณสูตรสำเร็จรูป (เช่น HyperFormula) แต่เขียน **เอนจินคำนวณสูตรขึ้นเอง** > ทั้ง tokenizer, parser, evaluator และฟังก์ชันต่างๆ เพื่อควบคุมพฤติกรรมได้เต็มที่ ดูรายละเอียดที่หัวข้อ @@ -2880,7 +2884,7 @@ src/ fillSeries.ts # ลากมุมแล้วได้อะไรต่อ — ชุดตัวเลข วันไทย เดือนไทย ไตรมาส และการขยับสูตร sheetSearch.ts # ค้นหา/แทนที่จากข้อความดิบ ไม่ใช่ผลลัพธ์ที่แสดง workbookRefs.ts # การแก้สูตรที่ไม่ใช่เรื่องของชีตเดียว — แทรกแถวข้ามชีต และเปลี่ยนชื่อชีต - fileLosses.ts # ไฟล์ที่เปิดมามีอะไรที่แอปเก็บไว้ไม่ได้ (รูป กราฟ Pivot มาโคร ลิงก์ ฟังก์ชันที่ไม่มี) อ่านจาก package (#83) + fileLosses.ts # ไฟล์ที่เปิดมามีอะไรที่แอปเก็บไว้ไม่ได้ (รูป กราฟ Pivot มาโคร ลิงก์ ฟังก์ชันที่ไม่มี ค่า error คงที่) อ่านจาก package (#83) sheetNames.ts # กติกาชื่อชีตของ Excel ที่เดียว — ≤31 ตัว, ไม่มี \ / ? * [ ] :, ไม่ซ้ำแม้ต่างตัวพิมพ์ (#54) byok.ts # เก็บ API key ของผู้ใช้ไว้ในแท็บเดียว + ปิดบังตอนแสดงผล sheet.ts # โมเดลข้อมูลตารางหลัก, คำนวณค่าทั้งชีต, ใส่สูตรตาม scope ต่างๆ, แทรก/ลบแถว-คอลัมน์ @@ -3517,13 +3521,13 @@ B อ่านและแก้ได้ แต่ยังยึดเป็ ## 🧪 การทดสอบ ```bash -npm test # 1974 เคส ใน 140 ไฟล์ ด้วย Vitest +npm test # 1979 เคส ใน 140 ไฟล์ ด้วย Vitest ``` โฟกัสเทสต์ไปที่ **เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, การแปลง JSON เป็นตาราง, การไล่ดึงหน้าถัดไป, การถอยเมื่อโดน rate limit, แม่แบบจากไฟล์ Excel, กฎจัดรูปแบบตามเงื่อนไข และการวางบล็อกข้อมูลสด** — ส่วนที่เป็น pure function ล้วน ไม่ต้องพึ่ง React/DOM จึงเทสต์ได้เร็วและมั่นใจได้สูง -**แต่ 1974 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์ +**แต่ 1979 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์ ทุกตัวแทบทั้งหมด — ปุ่ม "+ แถว" บนแถบเครื่องมือเรียก `addRow` ที่ไม่ประกาศอะไรเลย ขณะที่ `insertRowAtSelection` ข้าง ๆ ประกาศถูกต้อง (ทั้งคู่เทสต์ผ่าน) · ผู้ช่วย AI ส่งช่วงที่รวมหัวคอลัมน์ไปด้วย เพราะตัวประกอบ context อ่าน `sheet.cells` ที่เป็นสูตรดิบแทนค่าที่คำนวณแล้ว (ทั้งคู่เทสต์ผ่าน) · ปุ่มใหม่หนึ่งปุ่มดันปุ่มสลับภาษาตกขอบจอไป 42px @@ -3623,9 +3627,9 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด ในตารางแล้วสองข้อในflow ที่สามตกทันที (ครั้งแรกที่ลองปิดยังเขียวอยู่ เพราะ `case` ที่แทรกเข้าไปไปอยู่หลัง `case "ArrowRight"` เดิม กลายเป็นโค้ดตาย — การพิสูจน์ต้องเช็กด้วยว่าของที่ตั้งใจทำให้พังนั้นพังจริง) -> **1974 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้ +> **1979 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้ > มันจึงตอบตามที่คนเขียนเทสต์คิดว่ามันจะตอบ จำนวนเทสต์บอกได้แค่ว่าเราถามอะไรไปบ้าง ไม่ได้บอกว่าเราถาม -> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 1974 เคสจับไม่ได้](#-สิ่งที่เทสต์-1974-เคสจับไม่ได้) +> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 1979 เคสจับไม่ได้](#-สิ่งที่เทสต์-1979-เคสจับไม่ได้) > และรันซ้ำเองได้ด้วย `npm run check:ai` | ไฟล์ | เคส | ทดสอบอะไร | @@ -3666,8 +3670,8 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด | `sheetNames.test.ts` | 7 | กติกาชื่อชีตของ Excel (#54): ทุกข้อที่ผิด, ตัวพิมพ์เล็กใหญ่ถือเป็นชื่อเดียว, แก้ชื่อให้ถูกและหาชื่อที่ว่าง, ปุ่ม + ไม่ได้ชื่อซ้ำหลังลบแท็บ, งานเก่าที่ชื่อซ้ำ: แท็บแรกได้ชื่อเดิมและสูตรไม่ย้าย, ชื่อผิดกติกาเปลี่ยนพร้อมสูตร | | `sheetMerges.test.ts` | 35 | ช่องไหนถูก merge กลืน, การเลื่อน merge ตอนแทรก/ลบแถว-คอลัมน์, merge ที่เหลือช่องเดียวถูกทิ้ง | | `sheetTemplate.test.ts` | 14 | เซลล์ไหนล็อก/กรอกได้, ตัวเลือก dropdown แบบ inline และแบบอ้างช่วง, แปลงความกว้างคอลัมน์ | -| `fileLosses.test.ts` | 10 | ไฟล์ที่เปิดมามีอะไรที่เก็บไว้ไม่ได้ (#83): ไฟล์จริงที่มีรูป กราฟ และฟังก์ชันที่เอนจินไม่มี นับได้ครบพร้อมชื่อชีต, สูตรนั้นส่งออกไปเหมือนเดิม, ไฟล์ธรรมดาไม่มีรายงาน, `_xlfn.` ของฟังก์ชันที่มีไม่ถูกนับ, Pivot Table/รูปร่าง/มาโคร/ลิงก์ และ chart sheet ตามรูปแบบใน spec, ไฟล์ที่ไม่ใช่ zip ไม่ทำให้การเปิดล้ม, ชื่อช่วงที่นำเข้าไม่ได้ (#60) เป็นบรรทัดหนึ่งของรายงานเดียวกัน ต่อจากฟังก์ชันที่ไม่มี เป็นรายงานทั้งหมดของไฟล์ที่ไม่มีอย่างอื่น และยังบอกแม้อ่าน package ซ้ำไม่ได้ | -| `excelIO.test.ts` | 55 | สร้างไฟล์ .xlsx จริงแล้ววนครบรอบ: อ่านช่องกรอก/dropdown/ความกว้าง, ไฟล์ที่ไม่ protect ไม่ใช่แม่แบบ, export แล้ว import กลับได้เหมือนเดิม, และรูปแบบ (แถบสี/ขนาดตัวอักษร/เส้นขอบ/ความสูงแถว/merge) ครบรอบ รวมถึงกฎจัดรูปแบบตามเงื่อนไขทั้ง 5 แบบ และโน้ตของเซลล์ (ทั้งที่เขียนเป็นข้อความธรรมดาและ rich text ของ Excel), dropdown ข้ามชีตอ่านจากชีตที่กฎชี้ (ชื่อชีตมีเครื่องหมายคำพูด, ชีตซ่อน, ชีตที่ไม่มีอยู่, แม่แบบร่าง 0 ของ PaynEat ERP), คอลัมน์กว้าง 9 พอดีส่งออกแล้วยังเป็น 9 (#136), merge ในไฟล์มีค่าที่ซ้ายบนช่องเดียว SUM/COUNTA ตรงกับ Excel และ merge ยังส่งออกไป (#55) | +| `fileLosses.test.ts` | 12 | ไฟล์ที่เปิดมามีอะไรที่เก็บไว้ไม่ได้ (#83): ไฟล์จริงที่มีรูป กราฟ และฟังก์ชันที่เอนจินไม่มี นับได้ครบพร้อมชื่อชีต, สูตรนั้นส่งออกไปเหมือนเดิม, ไฟล์ธรรมดาไม่มีรายงาน, `_xlfn.` ของฟังก์ชันที่มีไม่ถูกนับ, Pivot Table/รูปร่าง/มาโคร/ลิงก์ และ chart sheet ตามรูปแบบใน spec, ไฟล์ที่ไม่ใช่ zip ไม่ทำให้การเปิดล้ม, ชื่อช่วงที่นำเข้าไม่ได้ (#60) เป็นบรรทัดหนึ่งของรายงานเดียวกัน ต่อจากฟังก์ชันที่ไม่มี เป็นรายงานทั้งหมดของไฟล์ที่ไม่มีอย่างอื่น และยังบอกแม้อ่าน package ซ้ำไม่ได้, เซลล์ error คงที่ (#227) นับพร้อมชื่อชีตแต่สูตรที่ผลเป็น error ไม่นับ และยังนับได้หลังส่งออกแล้วเปิดใหม่ | +| `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 | | `server/sourcesAuth.test.ts` | 9 | ไม่ตั้งโทเคน = ปิดทั้งหมด, โทเคนถูก/ผิด/เป็นแค่บางส่วน, แยก "ปิดอยู่" ออกจาก "โทเคนผิด" | diff --git a/public/screenshots/27-landing-stats.png b/public/screenshots/27-landing-stats.png index e188902..71229d0 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 eb41c8a..034f2cd 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 251e602..4519528 100644 --- a/public/screenshots/screenshots.json +++ b/public/screenshots/screenshots.json @@ -5,7 +5,7 @@ "27-landing-stats.png": { "paletteFormulas": 37, "engineFunctions": 66, - "tests": 1974, + "tests": 1979, "securityTests": 325 }, "38-landing-problems.png": { @@ -21,7 +21,7 @@ "en/27-landing-stats.png": { "paletteFormulas": 37, "engineFunctions": 66, - "tests": 1974, + "tests": 1979, "securityTests": 325 }, "en/38-landing-problems.png": { diff --git a/public/social-preview.png b/public/social-preview.png index e7f6b44..a610bf7 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 2d6c6bc..324440d 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"], - ["1974", "automated tests"], + ["1979", "automated tests"], ["325", "of them security"], ["0", "formula libraries"], ]; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 00b060a..ce855be 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -430,7 +430,7 @@ export const en: Messages = { stats: [ { value: "37", label: "ready-made formulas" }, { value: "66", label: "engine functions" }, - { value: "1974", label: "automated tests" }, + { value: "1979", label: "automated tests" }, { value: "325", label: "security tests" }, { value: "0", label: "formula libraries used" }, ], @@ -816,12 +816,15 @@ 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 429cd70..2f8e24b 100644 --- a/src/i18n/th.ts +++ b/src/i18n/th.ts @@ -429,7 +429,7 @@ export const th: Messages = { stats: [ { value: "37", label: "สูตรพร้อมใช้" }, { value: "66", label: "ฟังก์ชันในเอนจิน" }, - { value: "1974", label: "เทสต์อัตโนมัติ" }, + { value: "1979", label: "เทสต์อัตโนมัติ" }, { value: "325", label: "เทสต์ด้านความปลอดภัย" }, { value: "0", label: "ไลบรารีคำนวณสูตร" }, ], @@ -814,12 +814,14 @@ 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/excelIO.test.ts b/src/lib/excelIO.test.ts index 1f7a48a..f4a6f97 100644 --- a/src/lib/excelIO.test.ts +++ b/src/lib/excelIO.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import ExcelJS from "exceljs"; +import JSZip from "jszip"; import { describe, expect, it } from "vitest"; import { exportWorkbookToXlsxBlob, importWorkbookFromFile } from "./excelIO"; import { computeSheet, createEmptySheet, createWorkbookResolver, setCellRaw, SheetModel } from "./sheet"; @@ -705,3 +706,63 @@ describe("a merged cell imports as Excel holds it", () => { expect(ws.getCell("A1").value).toBe(10); }); }); + +/** + * A constant error cell, as Excel writes one: `#DIV/0!` with no formula (#227). + * The importer had no branch for it, so it opened blank and a total over it came out clean. + */ +async function errorCellsFile(): Promise { + 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; + return new File([await wb.xlsx.writeBuffer()], "errors.xlsx"); +} + +/** The `` element for one cell, straight from the package, so a test sees what Excel will read. */ +async function cellXml(data: Blob | ArrayBuffer, address: string): Promise { + const zip = await JSZip.loadAsync(data instanceof Blob ? await data.arrayBuffer() : data); + const xml = (await zip.file("xl/worksheets/sheet1.xml")?.async("string")) ?? ""; + return new RegExp(`]*?(?:/>|>[\\s\\S]*?)`).exec(xml)?.[0] ?? ""; +} + +describe("a constant error cell in an opened file (#227)", () => { + it("keeps its code instead of opening blank", async () => { + const file = await errorCellsFile(); + // The file holds the cells the way Excel writes them: an error type and no formula. + expect(await cellXml(await file.arrayBuffer(), "A1")).toMatch(/t="e"[^>]*>\s*#DIV\/0!<\/v>/); + expect(await cellXml(await file.arrayBuffer(), "A1")).not.toContain(""); + + const [{ sheet }] = await importWorkbookFromFile(file); + expect(sheet.cells.slice(0, 4).map((row) => row[0])).toEqual(["#DIV/0!", "#N/A", "5", "=SUM(A1:A3)"]); + }); + + it("goes back out as an error value, so Excel's own SUM over it still says #DIV/0!", async () => { + const [{ sheet }] = await importWorkbookFromFile(await errorCellsFile()); + const blob = await exportWorkbookToXlsxBlob(exportable(sheet, "ยอดขาย")); + expect(await cellXml(blob, "A1")).toMatch(/t="e"[^>]*>\s*#DIV\/0!<\/v>/); + expect(await cellXml(blob, "A2")).toMatch(/t="e"[^>]*>\s*#N\/A<\/v>/); + + // And open → export → open keeps them. + const [{ sheet: again }] = await importWorkbookFromFile(new File([await blob.arrayBuffer()], "again.xlsx")); + expect(again.cells.slice(0, 4).map((row) => row[0])).toEqual(["#DIV/0!", "#N/A", "5", "=SUM(A1:A3)"]); + }); + + it("text that only looks like an error stays text: after an apostrophe, or in a cell formatted as text", async () => { + const sheet = createEmptySheet(5, 2); + sheet.cells[0][0] = "'#N/A"; + sheet.cells[1][0] = "#N/A"; + sheet.formats[1][0] = { numberFormat: "text" }; + sheet.cells[2][0] = "#REF!"; + sheet.cells[3][0] = "#n/a"; + const ws = await readBack(await exportWorkbookToXlsxBlob(exportable(sheet, "Codes"))); + expect(ws.getCell("A1").value).toBe("#N/A"); + 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"); + }); +}); diff --git a/src/lib/excelIO.ts b/src/lib/excelIO.ts index c62d9fe..2159798 100644 --- a/src/lib/excelIO.ts +++ b/src/lib/excelIO.ts @@ -108,6 +108,13 @@ function cellValueToRaw(cell: ExcelJS.Cell): string { if ("text" in v && typeof v.text === "string") { return v.text; } + // A constant error, `#DIV/0!`. Kept as its code rather than dropped (#227): + // it opened blank, and a total over it came out as a clean number. The grid holds it as text + // until error values can live in a cell (#226); the open report says so, and export writes it + // back as the error it was. + if ("error" in v && typeof v.error === "string") { + return v.error; + } if ("result" in v) { const result = v.result; return result === undefined || result === null ? "" : String(result); @@ -693,6 +700,9 @@ export async function importWorkbookFromFile(file: File): Promise v !== undefined && v !== false); @@ -720,6 +730,11 @@ 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). diff --git a/src/lib/fileLosses.test.ts b/src/lib/fileLosses.test.ts index 95d4ad3..06de1bb 100644 --- a/src/lib/fileLosses.test.ts +++ b/src/lib/fileLosses.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { readFileSync } from "node:fs"; +import ExcelJS from "exceljs"; import JSZip from "jszip"; import { describe, expect, it } from "vitest"; import { findFileLosses, unknownFunctionsIn } from "./fileLosses"; @@ -156,3 +157,30 @@ describe("range names a file holds that cannot come in (#60), in the same report expect(losses).toEqual([{ kind: "names", count: 1, sheets: [], names: ["Dynamic"] }]); }); }); + +describe("constant error cells (#227), shown as text until #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(); + const sales = wb.addWorksheet("ยอดขาย"); + sales.getCell("A1").value = { error: "#DIV/0!" } as ExcelJS.CellErrorValue; + sales.getCell("A2").value = { error: "#N/A" } as ExcelJS.CellErrorValue; + sales.getCell("A3").value = 5; + sales.getCell("A4").value = { formula: "SUM(A1:A3)", result: { error: "#DIV/0!" } } as ExcelJS.CellFormulaValue; + const other = wb.addWorksheet("อื่นๆ"); + other.getCell("B2").value = { formula: "1/0", result: { error: "#DIV/0!" } } as ExcelJS.CellFormulaValue; + 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 () => { + 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: ["ยอดขาย"] }]); + }); +}); diff --git a/src/lib/fileLosses.ts b/src/lib/fileLosses.ts index 9ccd81b..d408e2c 100644 --- a/src/lib/fileLosses.ts +++ b/src/lib/fileLosses.ts @@ -22,7 +22,16 @@ 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"; +export type LossKind = + | "pictures" + | "shapes" + | "charts" + | "pivots" + | "macros" + | "externalLinks" + | "unknownFunctions" + | "names" + | "errorValues"; export interface FileLoss { kind: LossKind; @@ -36,9 +45,10 @@ 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. + * 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. */ -const ORDER: LossKind[] = ["pictures", "charts", "shapes", "pivots", "unknownFunctions", "names", "externalLinks", "macros"]; +const ORDER: LossKind[] = ["pictures", "charts", "shapes", "pivots", "unknownFunctions", "names", "errorValues", "externalLinks", "macros"]; interface Rel { type: string; @@ -78,6 +88,23 @@ 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; @@ -162,6 +189,8 @@ 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);