diff --git a/README.en.md b/README.en.md index 1d7e952..7360683 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), 2014 automated tests. +an undo that does not erase the other person's work. Bilingual UI (Thai/English), 2017 automated tests. --- @@ -109,7 +109,7 @@ Want the harder parts: [embedding a Thai font in the PDF, with stacked tone mark --- -### 🧪 What 2014 passing tests could not catch +### 🧪 What 2017 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 — 2014 tests, `axe` clean on +**And it happened again, in a different place.** With every gate green — 2017 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 2014-case Vitest suite | +| `npm test` | Run the 2017-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) | @@ -3045,7 +3045,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 (2014 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 (2017 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 @@ -3728,7 +3728,10 @@ seconds, not the time of day. 1.5 days in `[h]:mm` shows `36:00` (it used to sho `[h]:mm` shows the real total, in the grid and in `TEXT` alike. Zero and negative values show as Excel shows them too (checked in real Excel): 0 under `dd/mm/yyyy` is `00/01/1900` (it used to show `31/12/1899`, a date Excel never shows), a negative value under a date or time format shows `########` rather than an invented date, and `TEXT` of a -negative value under a date or time code gives `#VALUE!`. +negative value under a date or time code gives `#VALUE!`. Opening an `.xlsx` does the same (#241): a cell of 0 or a +negative number under a date or time format opens as that number with its format, formulas read it as a number, and +export writes the number back. It used to turn into the text `1899-12-31` in silence, which `SUM` skipped and export +wrote over the number in the file. **Not supported:** `MATCH` over a two-dimensional range — that returns `#N/A` rather than guessing a position inside a block. `MROUND` is not in the engine. Arithmetic that overflows (`10^308*10`) still @@ -4100,13 +4103,13 @@ the framework bundle itself, which isn't a trade worth making here. Written down ## 🧪 Testing ```bash -npm test # 2014 cases across 145 files, via Vitest +npm test # 2017 cases across 145 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 2014 cases opens the app**, and nearly every bug this project found by hand lived in +**But not one of those 2017 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` @@ -4225,10 +4228,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.) -> **2014 tests passed, and 43% of the assistant's answers were unusable** — because those tests mock +> **2017 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 2014 passing tests could not catch](#-what-2014-passing-tests-could-not-catch), repeatable +> [What 2017 passing tests could not catch](#-what-2017-passing-tests-could-not-catch), repeatable > with `npm run check:ai`. | File | Cases | Tests | @@ -4271,7 +4274,7 @@ code. Proving a gate means checking that the thing you meant to break actually b | `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` | 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) | +| `excelIO.test.ts` | 61 | 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); a cell of 0 or a negative number under a date or time format opening as the number, read by formulas as one, and exported back with its format (#241) | | `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 566cfd6..5401405 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), 2014 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง +(Thai/English), 2017 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง --- @@ -106,7 +106,7 @@ top/bottom ranks, colour scales and data bars — and round-trips through Excel' --- -### 🧪 สิ่งที่เทสต์ 2014 เคสจับไม่ได้ +### 🧪 สิ่งที่เทสต์ 2017 เคสจับไม่ได้ เทสต์ของผู้ช่วย 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 ที่มองเห็น เป็นตัวเลขผิดที่มองไม่เห็น** -**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 2014 เทสต์ผ่าน, `axe` ไม่มี violation ทั้งสองหน้า +**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 2017 เทสต์ผ่าน, `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 2014 เคสด้วย Vitest | +| `npm test` | รัน unit test 2017 เคสด้วย Vitest | | `npm run check:readme` | ตรวจว่า README ยังตรงกับโค้ด (ลิงก์/ภาพ/จำนวนเทสต์/โมดูลใหม่/สองภาษาตรงกัน) | | `npm run check:screens` | ตัวเลขที่พิมพ์อยู่บนภาพหน้าจอยังตรงกับซอร์สไหม | | `npm run check:rls` | ยิงจริงสองบัญชีบน Supabase ของคุณ ว่าฐานข้อมูลปฏิเสธตามที่ policy เขียนไว้ไหม (ต้องตั้ง env) | @@ -2647,7 +2647,7 @@ stack ของ `font-mono` จึงต่อท้ายด้วย Plex Sans | `@anthropic-ai/sdk` | เชื่อมต่อ Claude API สำหรับผู้ช่วย AI | | `lucide-react` | ไอคอน UI | | `clsx` | รวม className แบบมีเงื่อนไข | -| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (2014 เคส) | +| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (2017 เคส) | > **หมายเหตุ:** ไม่ได้ใช้ไลบรารีคำนวณสูตรสำเร็จรูป (เช่น HyperFormula) แต่เขียน **เอนจินคำนวณสูตรขึ้นเอง** > ทั้ง tokenizer, parser, evaluator และฟังก์ชันต่างๆ เพื่อควบคุมพฤติกรรมได้เต็มที่ ดูรายละเอียดที่หัวข้อ @@ -3264,6 +3264,8 @@ error อยู่แล้ว คือ `IF` `IFERROR` `IFNA` `COUNT` `COUNTA` 1.5 วันในรูปแบบ `[h]:mm` ขึ้น `36:00` (เดิมขึ้น `12:00`) ใบลงเวลาที่รวมชั่วโมงด้วย `[h]:mm` จึงได้ยอดจริง ทั้งในตารางและใน `TEXT` · ค่า 0 กับค่าติดลบก็ขึ้นแบบ Excel (ลองใน Excel จริงแล้ว): 0 ในรูปแบบ `dd/mm/yyyy` ขึ้น `00/01/1900` (เดิมขึ้น `31/12/1899` ซึ่งเป็นวันที่ที่ Excel ไม่เคยแสดง) ค่าติดลบในรูปแบบวันที่หรือเวลาขึ้น `########` แทนวันที่ที่แต่งขึ้น และ `TEXT` ของค่าติดลบได้ `#VALUE!` +· เปิดไฟล์ .xlsx ก็เหมือนกัน (#241): ช่อง 0 หรือค่าติดลบในรูปแบบวันที่หรือเวลาเปิดมาเป็นตัวเลขพร้อมรูปแบบเดิม สูตรที่อ่านช่องนั้นได้ตัวเลข และส่งออกกลับเป็นตัวเลข +(เดิมกลายเป็นข้อความ `1899-12-31` เงียบๆ `SUM` ข้ามช่องนั้น และส่งออกเขียนข้อความทับตัวเลขในไฟล์) **ยังไม่รองรับ:** `MATCH` ต้องเป็นช่วงแถวเดียวหรือคอลัมน์เดียว ถ้าส่งช่วงสองมิติจะได้ `#N/A` แทนการเดาตำแหน่งให้ · ยังไม่มี `MROUND` · การคำนวณที่ค่าล้น (`10^308*10`) ยังโชว์เป็น Infinity แทน `#NUM!` @@ -3556,13 +3558,13 @@ B อ่านและแก้ได้ แต่ยังยึดเป็ ## 🧪 การทดสอบ ```bash -npm test # 2014 เคส ใน 145 ไฟล์ ด้วย Vitest +npm test # 2017 เคส ใน 145 ไฟล์ ด้วย Vitest ``` โฟกัสเทสต์ไปที่ **เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, การแปลง JSON เป็นตาราง, การไล่ดึงหน้าถัดไป, การถอยเมื่อโดน rate limit, แม่แบบจากไฟล์ Excel, กฎจัดรูปแบบตามเงื่อนไข และการวางบล็อกข้อมูลสด** — ส่วนที่เป็น pure function ล้วน ไม่ต้องพึ่ง React/DOM จึงเทสต์ได้เร็วและมั่นใจได้สูง -**แต่ 2014 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์ +**แต่ 2017 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์ ทุกตัวแทบทั้งหมด — ปุ่ม "+ แถว" บนแถบเครื่องมือเรียก `addRow` ที่ไม่ประกาศอะไรเลย ขณะที่ `insertRowAtSelection` ข้าง ๆ ประกาศถูกต้อง (ทั้งคู่เทสต์ผ่าน) · ผู้ช่วย AI ส่งช่วงที่รวมหัวคอลัมน์ไปด้วย เพราะตัวประกอบ context อ่าน `sheet.cells` ที่เป็นสูตรดิบแทนค่าที่คำนวณแล้ว (ทั้งคู่เทสต์ผ่าน) · ปุ่มใหม่หนึ่งปุ่มดันปุ่มสลับภาษาตกขอบจอไป 42px @@ -3662,9 +3664,9 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด ในตารางแล้วสองข้อในflow ที่สามตกทันที (ครั้งแรกที่ลองปิดยังเขียวอยู่ เพราะ `case` ที่แทรกเข้าไปไปอยู่หลัง `case "ArrowRight"` เดิม กลายเป็นโค้ดตาย — การพิสูจน์ต้องเช็กด้วยว่าของที่ตั้งใจทำให้พังนั้นพังจริง) -> **2014 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้ +> **2017 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้ > มันจึงตอบตามที่คนเขียนเทสต์คิดว่ามันจะตอบ จำนวนเทสต์บอกได้แค่ว่าเราถามอะไรไปบ้าง ไม่ได้บอกว่าเราถาม -> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 2014 เคสจับไม่ได้](#-สิ่งที่เทสต์-2014-เคสจับไม่ได้) +> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 2017 เคสจับไม่ได้](#-สิ่งที่เทสต์-2017-เคสจับไม่ได้) > และรันซ้ำเองได้ด้วย `npm run check:ai` | ไฟล์ | เคส | ทดสอบอะไร | @@ -3707,7 +3709,7 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด | `sheetMerges.test.ts` | 35 | ช่องไหนถูก merge กลืน, การเลื่อน merge ตอนแทรก/ลบแถว-คอลัมน์, merge ที่เหลือช่องเดียวถูกทิ้ง | | `sheetTemplate.test.ts` | 14 | เซลล์ไหนล็อก/กรอกได้, ตัวเลือก dropdown แบบ inline และแบบอ้างช่วง, แปลงความกว้างคอลัมน์ | | `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) | +| `excelIO.test.ts` | 61 | สร้างไฟล์ .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), ช่อง 0 และค่าติดลบในรูปแบบวันที่/เวลาเปิดมาเป็นตัวเลข สูตรอ่านได้ และส่งออกกลับเป็นตัวเลขพร้อมรูปแบบ (#241) | | `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 68eac2d..079b0ab 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 343aeeb..b02bc92 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 af4988d..d337690 100644 --- a/public/screenshots/screenshots.json +++ b/public/screenshots/screenshots.json @@ -5,7 +5,7 @@ "27-landing-stats.png": { "paletteFormulas": 37, "engineFunctions": 66, - "tests": 2014, + "tests": 2017, "securityTests": 325 }, "38-landing-problems.png": { @@ -21,7 +21,7 @@ "en/27-landing-stats.png": { "paletteFormulas": 37, "engineFunctions": 66, - "tests": 2014, + "tests": 2017, "securityTests": 325 }, "en/38-landing-problems.png": { diff --git a/public/social-preview.png b/public/social-preview.png index 956e80b..49fcff1 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 bf61053..da9e52a 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"], - ["2014", "automated tests"], + ["2017", "automated tests"], ["325", "of them security"], ["0", "formula libraries"], ]; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 4e113d6..89a45f1 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -432,7 +432,7 @@ export const en: Messages = { stats: [ { value: "37", label: "ready-made formulas" }, { value: "66", label: "engine functions" }, - { value: "2014", label: "automated tests" }, + { value: "2017", label: "automated tests" }, { value: "325", label: "security tests" }, { value: "0", label: "formula libraries used" }, ], diff --git a/src/i18n/th.ts b/src/i18n/th.ts index c17e278..a0f6956 100644 --- a/src/i18n/th.ts +++ b/src/i18n/th.ts @@ -432,7 +432,7 @@ export const th: Messages = { stats: [ { value: "37", label: "สูตรพร้อมใช้" }, { value: "66", label: "ฟังก์ชันในเอนจิน" }, - { value: "2014", label: "เทสต์อัตโนมัติ" }, + { value: "2017", label: "เทสต์อัตโนมัติ" }, { value: "325", label: "เทสต์ด้านความปลอดภัย" }, { value: "0", label: "ไลบรารีคำนวณสูตร" }, ], diff --git a/src/lib/excelIO.test.ts b/src/lib/excelIO.test.ts index be84f61..0a3f380 100644 --- a/src/lib/excelIO.test.ts +++ b/src/lib/excelIO.test.ts @@ -5,6 +5,7 @@ import ExcelJS from "exceljs"; import JSZip from "jszip"; import { describe, expect, it } from "vitest"; import { exportWorkbookToXlsxBlob, importWorkbookFromFile } from "./excelIO"; +import { formatNumberForDisplay } from "./cellFormat"; import { computeSheet, createEmptySheet, createWorkbookResolver, setCellRaw, SheetModel } from "./sheet"; import { evaluateConditionalFormats } from "./conditionalFormat"; import { cellKey } from "./sheetTemplate"; @@ -766,3 +767,68 @@ describe("a constant error cell in an opened file (#227)", () => { expect(ws.getCell("A4").value).toEqual({ error: "#N/A" }); }); }); + +/** + * Zero and negative numbers under a date or time format, as a timesheet holds them (#241). + * ExcelJS hands such a cell over as a `Date`, and the importer wrote it as year-1899 ISO text that + * no date reading accepts: the cell became text, SUM skipped it and export wrote the text back. + */ +async function negativeTimesFile(): Promise { + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet("Hours"); + const put = (address: string, value: number, numFmt: string) => { + ws.getCell(address).value = value; + ws.getCell(address).numFmt = numFmt; + }; + put("A1", 0, "dd/mm/yyyy"); + put("A2", -1, "dd/mm/yyyy"); + put("A3", -1.5, "[h]:mm"); + put("A4", 1.5, "[h]:mm"); + put("A5", 45366, "dd/mm/yyyy"); + ws.getCell("A8").value = { formula: "SUM(A2:A4)", result: -1 } as ExcelJS.CellFormulaValue; + ws.getCell("A8").numFmt = "[h]:mm"; + ws.getCell("B2").value = { formula: "A2+2", result: 1 } as ExcelJS.CellFormulaValue; + return new File([await wb.xlsx.writeBuffer()], "hours.xlsx"); +} + +describe("zero and negative date or time cells in an opened file (#241)", () => { + const shown = (sheet: SheetModel, r: number, c: number) => { + const value = computeSheet(sheet).values[r][c]; + const format = sheet.formats[r][c]; + return typeof value === "number" && format?.numberFormat + ? formatNumberForDisplay(value, format.numberFormat, format.dateFormat, format.numFmtCode) + : String(value); + }; + + it("open as the numbers they are, shown as Excel shows them", async () => { + const [{ sheet }] = await importWorkbookFromFile(await negativeTimesFile()); + expect([0, 1, 2].map((r) => sheet.cells[r][0])).toEqual(["0", "-1", "-1.5"]); + expect([0, 1, 2, 3, 4].map((r) => computeSheet(sheet).values[r][0])).toEqual([0, -1, -1.5, 1.5, 45366]); + expect(shown(sheet, 0, 0)).toBe("00/01/1900"); + expect(shown(sheet, 1, 0)).toBe("########"); + expect(shown(sheet, 2, 0)).toBe("########"); + // Positive dates and times open as they always did. + expect(shown(sheet, 3, 0)).toBe("36:00"); + expect(shown(sheet, 4, 0)).toBe("15/03/2024"); + }); + + it("and formulas read them as numbers: A2+2 is 1, the total of A2:A4 is -1", async () => { + const [{ sheet }] = await importWorkbookFromFile(await negativeTimesFile()); + const { values } = computeSheet(sheet); + expect(values[1][1]).toBe(1); + expect(values[7][0]).toBe(-1); + }); + + it("and go back out as the same numbers with their formats", async () => { + const [{ sheet }] = await importWorkbookFromFile(await negativeTimesFile()); + const ws = await readBack(await exportWorkbookToXlsxBlob(exportable(sheet, "Hours"))); + const out = (address: string) => { + const value = ws.getCell(address).value; + // ExcelJS reads a date-formatted number back as a Date; turn it into its serial. + return value instanceof Date ? Math.round(((value.getTime() - Date.UTC(1899, 11, 30)) / 86_400_000) * 86_400) / 86_400 : value; + }; + expect(["A1", "A2", "A3", "A4", "A5"].map(out)).toEqual([0, -1, -1.5, 1.5, 45366]); + expect(["A1", "A2", "A3"].map((a) => ws.getCell(a).numFmt)).toEqual(["dd/mm/yyyy", "dd/mm/yyyy", "[h]:mm"]); + expect(await cellXml(await exportWorkbookToXlsxBlob(exportable(sheet, "Hours")), "A2")).not.toContain('t="s"'); + }); +}); diff --git a/src/lib/excelIO.ts b/src/lib/excelIO.ts index aab5e09..d5b6e4d 100644 --- a/src/lib/excelIO.ts +++ b/src/lib/excelIO.ts @@ -135,6 +135,10 @@ function rawForDate(d: Date, numFmt: string | undefined): string { const whole = Math.floor(serial); // Midnight in a time-only format is `00:00`, not a date: serial 0 has no day to show. const timeOnly = whole === 0 && (serial !== 0 || (!!numFmt && kindOfDateCode(numFmt) === "time")); + // Zero and below have no date to write: ISO text for them is a year-1899 day no date reading + // accepts, so the cell turned into text (#241). Kept as the number, its format shows it as Excel + // does (`00/01/1900`, `########`). + if (serial < 0 || (serial === 0 && !timeOnly)) return String(serial); const kind = timeOnly ? "time" : serial === whole ? "date" : "datetime"; return isoFromSerial(serial, kind); }