diff --git a/README.en.md b/README.en.md index a7ffbe0..8194923 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), 2010 automated tests. +an undo that does not erase the other person's work. Bilingual UI (Thai/English), 2013 automated tests. --- @@ -109,7 +109,7 @@ Want the harder parts: [embedding a Thai font in the PDF, with stacked tone mark --- -### 🧪 What 2010 passing tests could not catch +### 🧪 What 2013 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 — 2010 tests, `axe` clean on +**And it happened again, in a different place.** With every gate green — 2013 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 2010-case Vitest suite | +| `npm test` | Run the 2013-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) | @@ -3041,7 +3041,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 (2010 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 (2013 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 @@ -3721,7 +3721,10 @@ unformatted. **Elapsed time counts past a day, as Excel does (#219)** — `[h]`, `[mm]` and `[ss]` count the total hours, minutes or seconds, not the time of day. 1.5 days in `[h]:mm` shows `36:00` (it used to show `12:00`), so a timesheet totalled in -`[h]:mm` shows the real total, in the grid and in `TEXT` alike. +`[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!`. **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 @@ -4093,13 +4096,13 @@ the framework bundle itself, which isn't a trade worth making here. Written down ## 🧪 Testing ```bash -npm test # 2010 cases across 145 files, via Vitest +npm test # 2013 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 2010 cases opens the app**, and nearly every bug this project found by hand lived in +**But not one of those 2013 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` @@ -4218,10 +4221,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.) -> **2010 tests passed, and 43% of the assistant's answers were unusable** — because those tests mock +> **2013 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 2010 passing tests could not catch](#-what-2010-passing-tests-could-not-catch), repeatable +> [What 2013 passing tests could not catch](#-what-2013-passing-tests-could-not-catch), repeatable > with `npm run check:ai`. | File | Cases | Tests | @@ -4252,7 +4255,7 @@ code. Proving a gate means checking that the thing you meant to break actually b | `moveRefs.test.ts` | 7 | Reference rewriting for a cut and paste (#51): references into the block follow it with their `$`, everything outside and an untouched formula's text stay as they were, a range moves only when all of it was cut, a sheet name is added across sheets, a moved formula keeps naming the sheet it came from, a reference to a cell the block landed on becomes `#REF!`, and a cut under a filter moves only the rows it took | | `sheetSort.test.ts` | 13 | The bounds/header-detection heuristic, and sorting itself (blank values, limited column scope) · The header stays on top either way (#49): numbers under a word in any column, a label over an empty column, bold, a row with a number is not a header, a plain word list sorts whole | | `jsonToTable.test.ts` | 14 | Finding the record array in a response, flattening nested objects, numeric-column detection, single-row KPI objects, an empty list in an envelope as no rows rather than a row of the envelope (#65), CSV codes keeping their leading zeros all the way to the sheet (#36) | -| `excelDate.test.ts` | 17 | Dates as Excel keeps them (#45): 1900-system serials matching Excel, including its 29 Feb 1900; the ISO forms read as dates (others stay text); Excel date format codes (`dd/mm/yyyy`, `d-mmm-yy`, `h:mm AM/PM`) and elapsed time past a day (`[h]:mm` of 1.5 days is `36:00`, #219) · Buddhist-Era dates (#82): every form in the issue as 15 Jan 2026, all twelve Thai months full, short and dotless, a two-digit year beside a Thai month, what must not be guessed (`2569`, `15/01/69`, `15/01/2024`), every Thai-calendar code plus `bbbb` and Thai month names | +| `excelDate.test.ts` | 19 | Dates as Excel keeps them (#45): 1900-system serials matching Excel, including its 29 Feb 1900; the ISO forms read as dates (others stay text); Excel date format codes (`dd/mm/yyyy`, `d-mmm-yy`, `h:mm AM/PM`) and elapsed time past a day (`[h]:mm` of 1.5 days is `36:00`, #219), 0 as `00/01/1900` and a negative value as `########` in the grid (#219) · Buddhist-Era dates (#82): every form in the issue as 15 Jan 2026, all twelve Thai months full, short and dotless, a two-digit year beside a Thai month, what must not be guessed (`2569`, `15/01/69`, `15/01/2024`), every Thai-calendar code plus `bbbb` and Thai month names | | `dateConvert.test.ts` | 6 | "Convert to dates" (#82): a two-digit B.E. year is 25yy and an A.D. one follows Excel's window, month-first and year-first orders, dates already readable written as ISO, unreadable text left alone, a range planned without empty cells or formulas | | `numberFormatCode.test.ts` | 6 | Excel number-format codes (#53): the issue's table (7%, 12.5%, $1,234.50, 1.235, 12,345), the old `0.00"%"` not multiplied, negative/zero sections, currency tags, scaling commas, literals between digits, and the fallback for fractions and scientific codes | | `numberFormats.test.ts` | 5 | Percent, currency and decimals through a real .xlsx both ways (#53): a file's codes shown and exported unchanged, the app's Percent ×100 and exported as `0.00%`, and old sheets in the browser and the cloud (format 1) still reading 50.00% | diff --git a/README.md b/README.md index f987d96..5722908 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), 2010 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง +(Thai/English), 2013 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง --- @@ -106,7 +106,7 @@ top/bottom ranks, colour scales and data bars — and round-trips through Excel' --- -### 🧪 สิ่งที่เทสต์ 2010 เคสจับไม่ได้ +### 🧪 สิ่งที่เทสต์ 2013 เคสจับไม่ได้ เทสต์ของผู้ช่วย 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 ที่มองเห็น เป็นตัวเลขผิดที่มองไม่เห็น** -**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 2010 เทสต์ผ่าน, `axe` ไม่มี violation ทั้งสองหน้า +**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 2013 เทสต์ผ่าน, `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 2010 เคสด้วย Vitest | +| `npm test` | รัน unit test 2013 เคสด้วย Vitest | | `npm run check:readme` | ตรวจว่า README ยังตรงกับโค้ด (ลิงก์/ภาพ/จำนวนเทสต์/โมดูลใหม่/สองภาษาตรงกัน) | | `npm run check:screens` | ตัวเลขที่พิมพ์อยู่บนภาพหน้าจอยังตรงกับซอร์สไหม | | `npm run check:rls` | ยิงจริงสองบัญชีบน Supabase ของคุณ ว่าฐานข้อมูลปฏิเสธตามที่ policy เขียนไว้ไหม (ต้องตั้ง env) | @@ -2644,7 +2644,7 @@ stack ของ `font-mono` จึงต่อท้ายด้วย Plex Sans | `@anthropic-ai/sdk` | เชื่อมต่อ Claude API สำหรับผู้ช่วย AI | | `lucide-react` | ไอคอน UI | | `clsx` | รวม className แบบมีเงื่อนไข | -| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (2010 เคส) | +| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (2013 เคส) | > **หมายเหตุ:** ไม่ได้ใช้ไลบรารีคำนวณสูตรสำเร็จรูป (เช่น HyperFormula) แต่เขียน **เอนจินคำนวณสูตรขึ้นเอง** > ทั้ง tokenizer, parser, evaluator และฟังก์ชันต่างๆ เพื่อควบคุมพฤติกรรมได้เต็มที่ ดูรายละเอียดที่หัวข้อ @@ -3259,6 +3259,8 @@ error อยู่แล้ว คือ `IF` `IFERROR` `IFNA` `COUNT` `COUNTA` **เวลาสะสมนับข้ามวันแบบ Excel (#219)** — รหัส `[h]` `[mm]` `[ss]` นับชั่วโมง นาที หรือวินาทีรวมทั้งหมด ไม่ใช่เวลาของวัน 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!` **ยังไม่รองรับ:** `MATCH` ต้องเป็นช่วงแถวเดียวหรือคอลัมน์เดียว ถ้าส่งช่วงสองมิติจะได้ `#N/A` แทนการเดาตำแหน่งให้ · ยังไม่มี `MROUND` · การคำนวณที่ค่าล้น (`10^308*10`) ยังโชว์เป็น Infinity แทน `#NUM!` @@ -3551,13 +3553,13 @@ B อ่านและแก้ได้ แต่ยังยึดเป็ ## 🧪 การทดสอบ ```bash -npm test # 2010 เคส ใน 145 ไฟล์ ด้วย Vitest +npm test # 2013 เคส ใน 145 ไฟล์ ด้วย Vitest ``` โฟกัสเทสต์ไปที่ **เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, การแปลง JSON เป็นตาราง, การไล่ดึงหน้าถัดไป, การถอยเมื่อโดน rate limit, แม่แบบจากไฟล์ Excel, กฎจัดรูปแบบตามเงื่อนไข และการวางบล็อกข้อมูลสด** — ส่วนที่เป็น pure function ล้วน ไม่ต้องพึ่ง React/DOM จึงเทสต์ได้เร็วและมั่นใจได้สูง -**แต่ 2010 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์ +**แต่ 2013 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์ ทุกตัวแทบทั้งหมด — ปุ่ม "+ แถว" บนแถบเครื่องมือเรียก `addRow` ที่ไม่ประกาศอะไรเลย ขณะที่ `insertRowAtSelection` ข้าง ๆ ประกาศถูกต้อง (ทั้งคู่เทสต์ผ่าน) · ผู้ช่วย AI ส่งช่วงที่รวมหัวคอลัมน์ไปด้วย เพราะตัวประกอบ context อ่าน `sheet.cells` ที่เป็นสูตรดิบแทนค่าที่คำนวณแล้ว (ทั้งคู่เทสต์ผ่าน) · ปุ่มใหม่หนึ่งปุ่มดันปุ่มสลับภาษาตกขอบจอไป 42px @@ -3657,9 +3659,9 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด ในตารางแล้วสองข้อในflow ที่สามตกทันที (ครั้งแรกที่ลองปิดยังเขียวอยู่ เพราะ `case` ที่แทรกเข้าไปไปอยู่หลัง `case "ArrowRight"` เดิม กลายเป็นโค้ดตาย — การพิสูจน์ต้องเช็กด้วยว่าของที่ตั้งใจทำให้พังนั้นพังจริง) -> **2010 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้ +> **2013 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้ > มันจึงตอบตามที่คนเขียนเทสต์คิดว่ามันจะตอบ จำนวนเทสต์บอกได้แค่ว่าเราถามอะไรไปบ้าง ไม่ได้บอกว่าเราถาม -> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 2010 เคสจับไม่ได้](#-สิ่งที่เทสต์-2010-เคสจับไม่ได้) +> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 2013 เคสจับไม่ได้](#-สิ่งที่เทสต์-2013-เคสจับไม่ได้) > และรันซ้ำเองได้ด้วย `npm run check:ai` | ไฟล์ | เคส | ทดสอบอะไร | @@ -3690,7 +3692,7 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด | `moveRefs.test.ts` | 7 | การเขียนอ้างอิงใหม่ตอนตัดแล้ววาง (#51): อ้างอิงเข้าก้อนตามไปพร้อม `$`, นอกก้อนและข้อความสูตรที่ไม่ถูกแตะคงเดิม, ช่วงย้ายเมื่อถูกตัดทั้งก้อนเท่านั้น, ข้ามชีตใส่ชื่อชีตให้, สูตรที่ถูกย้ายยังชี้ชีตเดิม, อ้างเซลล์ที่ถูกวางทับเป็น `#REF!`, ตัดใต้ตัวกรองย้ายเฉพาะแถวที่ตัด | | `sheetSort.test.ts` | 13 | ฮิวริสติกตรวจจับขอบเขต+หัวตาราง และการเรียงลำดับ (รวมกรณีค่าว่าง, จำกัดคอลัมน์ที่ย้าย) · หัวตารางอยู่บนทั้งสองทิศ (#49): ตัวเลขใต้คำในคอลัมน์ไหนก็ได้, ป้ายเหนือคอลัมน์ว่าง, ตัวหนา, แถวที่มีตัวเลขไม่ใช่หัว, รายการคำล้วนเรียงทั้งหมด | | `jsonToTable.test.ts` | 14 | หา array ของ record ในการตอบกลับ, แตก object ซ้อนเป็นคอลัมน์, ตรวจจับคอลัมน์ตัวเลข, KPI object แถวเดียว, รายการว่างในซองได้ 0 แถว ไม่ใช่แถวของซอง (#65), CSV เก็บรหัสที่ขึ้นต้นด้วยศูนย์เป็นข้อความจนถึงตาราง (#36) | -| `excelDate.test.ts` | 17 | วันที่แบบ Excel (#45): serial ระบบ 1900 ตรงกับ Excel รวม 29 ก.พ. 1900 ที่ Excel ถือว่ามี, รูปแบบ ISO ที่อ่านเป็นวันที่ (รูปอื่นเป็นข้อความ), รหัสรูปแบบวันที่ของ Excel (`dd/mm/yyyy`, `d-mmm-yy`, `h:mm AM/PM`), เวลาสะสมเกินวัน (`[h]:mm` ของ 1.5 วันเป็น `36:00`, #219) · วันที่แบบ พ.ศ. (#82): ทุกรูปใน issue เป็น 15 ม.ค. 2026, เดือนไทยครบ 12 เดือนทั้งเต็ม/ย่อ/ไม่มีจุด, ปี 2 หลักคู่เดือนไทย, สิ่งที่ต้องไม่เดา (`2569`, `15/01/69`, `15/01/2024`), รหัสปฏิทินพุทธทุกแบบ + `bbbb` + ชื่อเดือนไทย | +| `excelDate.test.ts` | 19 | วันที่แบบ Excel (#45): serial ระบบ 1900 ตรงกับ Excel รวม 29 ก.พ. 1900 ที่ Excel ถือว่ามี, รูปแบบ ISO ที่อ่านเป็นวันที่ (รูปอื่นเป็นข้อความ), รหัสรูปแบบวันที่ของ Excel (`dd/mm/yyyy`, `d-mmm-yy`, `h:mm AM/PM`), เวลาสะสมเกินวัน (`[h]:mm` ของ 1.5 วันเป็น `36:00`, #219), 0 เป็น `00/01/1900` และค่าติดลบเป็น `########` ในตาราง (#219) · วันที่แบบ พ.ศ. (#82): ทุกรูปใน issue เป็น 15 ม.ค. 2026, เดือนไทยครบ 12 เดือนทั้งเต็ม/ย่อ/ไม่มีจุด, ปี 2 หลักคู่เดือนไทย, สิ่งที่ต้องไม่เดา (`2569`, `15/01/69`, `15/01/2024`), รหัสปฏิทินพุทธทุกแบบ + `bbbb` + ชื่อเดือนไทย | | `dateConvert.test.ts` | 6 | "แปลงเป็นวันที่" (#82): ปี 2 หลัก พ.ศ. = 25yy, ค.ศ. ตามหน้าต่าง Excel, เดือน/วัน/ปี และปีก่อน, วันที่ที่อ่านได้อยู่แล้วเขียนเป็น ISO, สิ่งที่อ่านไม่ออกไม่ถูกแตะ, แผนของช่วงที่ไม่รวมช่องว่างและสูตร | | `numberFormatCode.test.ts` | 6 | รหัสรูปแบบตัวเลขแบบ Excel (#53): ตารางจาก issue (7%, 12.5%, $1,234.50, 1.235, 12,345), `0.00"%"` แบบเดิมไม่ ×100, ส่วนติดลบ/ศูนย์, แท็กสกุลเงิน, จุลภาคหารพัน, ตัวอักษรคั่นกลางเลข และ fallback ของเศษส่วน/เลขวิทยาศาสตร์ | | `numberFormats.test.ts` | 5 | เปอร์เซ็นต์/สกุลเงิน/ทศนิยมผ่าน .xlsx จริงทั้งไป-กลับ (#53): รหัสของไฟล์แสดงและส่งออกกลับตามเดิม, เปอร์เซ็นต์ของแอป ×100 และส่งออกเป็น `0.00%`, ชีตเก่าในเบราว์เซอร์และบนคลาวด์ (format 1) ยังขึ้น 50.00% | diff --git a/public/screenshots/27-landing-stats.png b/public/screenshots/27-landing-stats.png index 706e20e..8321550 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 6664c6d..081a6ce 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 8d01c30..b9ad01a 100644 --- a/public/screenshots/screenshots.json +++ b/public/screenshots/screenshots.json @@ -5,7 +5,7 @@ "27-landing-stats.png": { "paletteFormulas": 37, "engineFunctions": 66, - "tests": 2010, + "tests": 2013, "securityTests": 325 }, "38-landing-problems.png": { @@ -21,7 +21,7 @@ "en/27-landing-stats.png": { "paletteFormulas": 37, "engineFunctions": 66, - "tests": 2010, + "tests": 2013, "securityTests": 325 }, "en/38-landing-problems.png": { diff --git a/public/social-preview.png b/public/social-preview.png index 59ed9e4..9352101 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 9d3c81c..88ff13f 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"], - ["2010", "automated tests"], + ["2013", "automated tests"], ["325", "of them security"], ["0", "formula libraries"], ]; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 39d7300..c6579d3 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: "2010", label: "automated tests" }, + { value: "2013", 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 5c8f918..5ac644b 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: "2010", label: "เทสต์อัตโนมัติ" }, + { value: "2013", label: "เทสต์อัตโนมัติ" }, { value: "325", label: "เทสต์ด้านความปลอดภัย" }, { value: "0", label: "ไลบรารีคำนวณสูตร" }, ], diff --git a/src/lib/cellFormat.ts b/src/lib/cellFormat.ts index 0db866f..27d4b89 100644 --- a/src/lib/cellFormat.ts +++ b/src/lib/cellFormat.ts @@ -1,7 +1,7 @@ // Copyright 2026 Suruch Chakrapeesirisuk // SPDX-License-Identifier: Apache-2.0 -import { BE_DATE_CODE, DEFAULT_DATE_CODE, formatSerial, isDateFormatCode, kindOfDateCode } from "./excelDate"; +import { BE_DATE_CODE, DEFAULT_DATE_CODE, formatSerial, isDateFormatCode, kindOfDateCode, NOT_A_DATE, showableAsDate } from "./excelDate"; import { formatNumberCode, isPercentCode } from "./numberFormatCode"; /** @@ -90,7 +90,7 @@ export function pxToPt(px: number | undefined): number | undefined { * (`numCode`) wins over the preset, so `0%` and `"$"#,##0.00` show as the file has them. */ export function formatNumberForDisplay(value: number, fmt: NumberFormat, dateCode?: string, numCode?: string): string { - if (isDateFormat(fmt)) return formatSerial(value, dateCode ?? DEFAULT_DATE_CODE[fmt]); + if (isDateFormat(fmt)) return showableAsDate(value) ? formatSerial(value, dateCode ?? DEFAULT_DATE_CODE[fmt]) : NOT_A_DATE; if (numCode) return formatNumberCode(value, numCode); const fixed2 = (n: number) => n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); switch (fmt) { diff --git a/src/lib/excelDate.test.ts b/src/lib/excelDate.test.ts index 857dead..7d731c6 100644 --- a/src/lib/excelDate.test.ts +++ b/src/lib/excelDate.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { dateLiteral, formatSerial, isDateFormatCode, isoFromSerial, partsOfSerial, serialOf } from "./excelDate"; +import { formatNumberForDisplay } from "./cellFormat"; /** * Dates as Excel keeps them (#45): a serial number of days, 1900 system, with the 29 February 1900 @@ -158,3 +159,17 @@ describe("a Buddhist-Era format shows the year Thai Excel does (#82)", () => { for (const code of ["[$-107041E]d/m/yyyy;@", "dd/mm/bbbb", "[$-th-TH,107]d/m/yyyy"]) expect(isDateFormatCode(code), code).toBe(true); }); }); + +// Checked in Excel (#219): a cell holding 0 under dd/mm/yyyy shows 00/01/1900, and one holding a +// negative number under a date or [h]:mm code is filled with #. It used to show 31/12/1899 and +// 30/12/1899, dates Excel never shows. +describe("zero and negative values under a date or time format (#219)", () => { + it("shows 0 as 00/01/1900 in the grid", () => { + expect(formatNumberForDisplay(0, "date", "dd/mm/yyyy")).toBe("00/01/1900"); + }); + + it("fills a negative value with # in the grid instead of inventing a date", () => { + expect(formatNumberForDisplay(-1, "date", "dd/mm/yyyy")).toBe("########"); + expect(formatNumberForDisplay(-1.5, "time", "[h]:mm")).toBe("########"); + }); +}); diff --git a/src/lib/excelDate.ts b/src/lib/excelDate.ts index 5845f9d..724de5d 100644 --- a/src/lib/excelDate.ts +++ b/src/lib/excelDate.ts @@ -302,6 +302,17 @@ function tokenizeWithLocale(code: string): { tokens: Token[]; locale: DateLocale return { tokens: out, locale }; } +/** + * Whether a serial can be shown under a date or time code. A negative one cannot (#219, checked in + * Excel): the grid fills the cell with `#` and `TEXT` gives `#VALUE!`, under the 1900 date system. + */ +export function showableAsDate(serial: number): boolean { + return Number.isFinite(serial) && serial >= 0; +} + +/** What a cell shows for a value that cannot be a date, as Excel fills it at its default width. */ +export const NOT_A_DATE = "########"; + /** * A serial shown the way an Excel format code says: `dd/mm/yyyy`, `d-mmm-yy`, `h:mm AM/PM`. * @@ -312,7 +323,11 @@ function tokenizeWithLocale(code: string): { tokens: Token[]; locale: DateLocale * year in any code (#82). Other locale tags (`[$-409]`) are read past. */ export function formatSerial(serial: number, code: string): string { - const p = partsOfSerial(serial); + // Serial 0 is Excel's 0 January 1900 (`00/01/1900`), not the day before 1 January (#219, + // checked in Excel). Only the display moves: `partsOfSerial` still answers 31 December 1899 for + // the date functions and for the ISO text a cell keeps, which have no day 0 to write. + const zero = Math.floor(Math.round(serial * 86_400) / 86_400) === 0; + const p = zero ? { ...partsOfSerial(serial), year: 1900, month: 1, day: 0 } : partsOfSerial(serial); const { tokens, locale } = tokenizeWithLocale(code); const months = locale.thai ? THAI_MONTHS : MONTHS; const shortMonth = (m: number) => (locale.thai ? THAI_MONTHS_SHORT[m - 1] : MONTHS[m - 1].slice(0, 3)); diff --git a/src/lib/formulaEngine/functions.test.ts b/src/lib/formulaEngine/functions.test.ts index fddf6cf..b951b03 100644 --- a/src/lib/formulaEngine/functions.test.ts +++ b/src/lib/formulaEngine/functions.test.ts @@ -427,6 +427,16 @@ describe("TEXT writes the value the way its format says, as Excel does (#29)", ( expect(calc('TEXT(0.1,"[mm]:ss")')).toBe("144:00"); expect(calc('TEXT(0.5,"[s]")')).toBe("43200"); }); + + // Checked in Excel (#219): serial 0 is 0 January 1900, and a negative serial is no date at all. + it("shows serial 0 as Excel's 00/01/1900 and refuses a negative one with #VALUE!", () => { + expect(calc('TEXT(0,"dd/mm/yyyy")')).toBe("00/01/1900"); + for (const f of ['TEXT(-1,"dd/mm/yyyy")', 'TEXT(-0.5,"hh:mm")', 'TEXT(-1,"[h]:mm")']) { + expect(String(calc(f)), f).toBe("#VALUE!"); + } + // A negative number under a number code is still just a number. + expect(calc('TEXT(-1,"0.0")')).toBe("-1.0"); + }); }); describe("the *IF(S) functions pass on an error they read, as Excel does (#96)", () => { diff --git a/src/lib/formulaEngine/functions.ts b/src/lib/formulaEngine/functions.ts index c6f8365..d456e5e 100644 --- a/src/lib/formulaEngine/functions.ts +++ b/src/lib/formulaEngine/functions.ts @@ -3,7 +3,7 @@ import { EvalResult, FormulaError, FormulaValue, flattenResult, isError, ERR_DIV0, ERR_NA, ERR_NUM, ERR_VALUE } from "./types"; import { toBoolean, toDisplayString, toNumber, isBlank } from "./coerce"; -import { dateLiteral, formatSerial, isDateFormatCode, partsOfSerial, serialOf } from "../excelDate"; +import { dateLiteral, formatSerial, isDateFormatCode, partsOfSerial, serialOf, showableAsDate } from "../excelDate"; import { formatNumberCode } from "../numberFormatCode"; /** Whether an argument was read from cells — a range, or one cell by reference (#166). */ @@ -673,7 +673,9 @@ export const FUNCTIONS: Record = { const n = isBlank(v) ? 0 : toNumber(v); if (isError(n)) return toDisplayString(v); if (code === "") return ""; - return isDateFormatCode(code) ? formatSerial(n, code) : formatNumberCode(n, code); + if (!isDateFormatCode(code)) return formatNumberCode(n, code); + // A negative serial is no date at all, and Excel says so rather than inventing one (#219). + return showableAsDate(n) ? formatSerial(n, code) : ERR_VALUE; }, // Both are serials (#45), in the reader's own time zone: NOW used to be UTC while TODAY was local, // so in Bangkok between midnight and 7am the two disagreed about the date.