diff --git a/README.en.md b/README.en.md index ce618d9..63540bd 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), 1992 automated tests. +an undo that does not erase the other person's work. Bilingual UI (Thai/English), 2007 automated tests. --- @@ -109,7 +109,7 @@ Want the harder parts: [embedding a Thai font in the PDF, with stacked tone mark --- -### 🧪 What 1992 passing tests could not catch +### 🧪 What 2007 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 — 1992 tests, `axe` clean on +**And it happened again, in a different place.** With every gate green — 2007 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 1992-case Vitest suite | +| `npm test` | Run the 2007-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) | @@ -1185,8 +1185,15 @@ believe they had one. automatically, and grows the sheet if the pasted block is bigger than the current table. - **Cut works across sheets** — cut on one sheet, paste on another: the source cells are cleared, the destination changes only where the block lands, and one undo puts both sheets back. If the source sheet was deleted before - the paste, the paste is a copy and clears nothing. **Not supported yet:** formulas that pointed at the moved cells - still point at the old address rather than following them, as Excel's would. + the paste, the paste is a copy and clears nothing. +- **Cut and paste is a move, as in Excel** (#51) — formulas that pointed at the moved cells follow them, on every + sheet and in every range name (`B1` holding `=A1*2` becomes `=D1*2` when A1 moves to D1, with the same value, + rather than reading an empty cell as 0). A moved formula keeps pointing where it did instead of shifting like a + copy (`=A1*2` moved from B1 to C5 is still `=A1*2`). A range follows only when all of it was cut; one that reaches + outside the block stays put. Moved to another sheet, a reference picks up the sheet's name. A block moved onto + part of itself empties only what it left. A formula that read a destination cell the paste covered becomes `#REF!`, + as in Excel (checked in real Excel). **Not supported yet:** a range reaching into the landing area stays as it was, + and the source cells keep their formatting rather than it moving with them. ### 🎨 Cell formatting @@ -3030,7 +3037,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 (1992 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 (2007 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 @@ -3285,7 +3292,7 @@ src/ toolbar/LanguageToggle.tsx # The UI language switch button lib/ # Core domain logic, no React/UI coupling — editable/testable independently formulaEngine/ # The hand-written formula engine — tokenizer.ts, parser.ts, ast.ts, evaluator.ts, - # functions.ts, coerce.ts, address.ts, shift.ts, structuralShift.ts, + # functions.ts, coerce.ts, address.ts, shift.ts, structuralShift.ts, moveRefs.ts, # formulaProgram.ts (AST cache + what each formula reads), # each with a matching *.test.ts run by Vitest formulaCatalog.ts # The ready-made formula catalog's structure (id/params/how to build it) — the @@ -3567,6 +3574,10 @@ thing: | A reference that lands exactly on a deleted position | Never happens in this path | Becomes `#REF!` | | A range spanning the insert/delete point | Shifts the whole range equally | **Grows/shrinks** as appropriate (insert in the middle → range gets longer; delete an edge until empty → `#REF!`) | +A cut and paste is a third case (`moveRefs.ts`, #51), and neither of the two above: the moved formulas do not shift +at all, while references pointing *into* the moved block, on any sheet or in any range name, follow it to its new +place, `$A$1` included. A range follows only when all of it was cut. + ### Supported functions The drag-and-drop palette shows only the **37 most commonly used** formulas, but the engine itself supports @@ -4078,13 +4089,13 @@ the framework bundle itself, which isn't a trade worth making here. Written down ## 🧪 Testing ```bash -npm test # 1992 cases across 142 files, via Vitest +npm test # 2007 cases across 144 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 1992 cases opens the app**, and nearly every bug this project found by hand lived in +**But not one of those 2007 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` @@ -4203,10 +4214,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.) -> **1992 tests passed, and 43% of the assistant's answers were unusable** — because those tests mock +> **2007 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 1992 passing tests could not catch](#-what-1992-passing-tests-could-not-catch), repeatable +> [What 2007 passing tests could not catch](#-what-2007-passing-tests-could-not-catch), repeatable > with `npm run check:ai`. | File | Cases | Tests | @@ -4234,6 +4245,7 @@ code. Proving a gate means checking that the thing you meant to break actually b | `formulaCatalog.test.ts` | 12 | What the palette actually builds: criteria quoting, a half-filled second condition, and every formula having text in both languages | | `shift.test.ts` | 12 | Relative reference shifting on copy/paste; absolute references staying put | | `structuralShift.test.ts` | 16 | Reference adjustment on row/column insert/delete, including `#REF!` and range grow/shrink | +| `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 | @@ -4276,6 +4288,7 @@ code. Proving a gate means checking that the thing you meant to break actually b | `store/pivotSize.test.ts` | 6 | A summary sheet as big as its answer (#56): all 35 products with the Grand total row in bold, all twelve months with the Grand total column, the same after a refresh, a small result still opening on a 30×10 sheet with room to work, a result too big for a sheet built nowhere and its size said, and a refresh too big for one alerting while the old pivot stays | | `store/pivotError.test.ts` | 2 | A pivot over a source with an error (#222): Sum, Average, Min and Max of the group holding `#DIV/0!` and the Grand total show `#DIV/0!` while the other group keeps its number, when built and when refreshed after the source gains the error | | `store/errorLiterals.test.ts` | 4 | An error code held in a cell as a value is the error (#226), by every way in: typed (`SUM` gives `#DIV/0!`, `IFERROR`/`IFNA` catch it), pasted as Excel copies it (`#1`, `#N/A please`, `'#N/A` stay text), opened from a file (a `t="e"` cell is the error, a string cell reading `#N/A` stays text), and `SUM`/`IFERROR` over a pivot sheet holding an error | +| `store/cutMove.test.ts` | 8 | Cut and paste is a move, as in Excel (#51): a formula reading the moved cell follows it with the same value, a moved formula keeps pointing where it did, a formula reading a cell the paste covered says `#REF!`, a block that refers to itself still does at its new place, a block moved onto part of itself empties what it left and moves references once, a range name follows, across sheets with one undo, and a copy still shifting as a copy | | `store/dateEntry.test.ts` | 5 | Entering a date as a person does (#45): `=TODAY()`/`=DATE()`/`=NOW()` formatted as dates while the cell is General, a date and time widening a default-width column, picking a date format replacing the file's layout | | `store/convertDates.test.ts` | 5 | "Convert to dates" and "Date (B.E.)" through the store (#82): converted cells show the Buddhist year while unreadable ones and formulas stay, A.D. month-first, one undo for all, a chosen format kept, the B.E. format shown and switched back | | `store/sampleNotice.test.ts` | 19 | Opening blank, the sample opened by its button and undone, a sample left in the browser (either language) not restored while one edit of any kind makes it work, New file undone and announced, the sample notice only while untouched, and the sample following the language on screen | diff --git a/README.md b/README.md index d964fc5..eb5a201 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), 1992 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง +(Thai/English), 2007 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง --- @@ -106,7 +106,7 @@ top/bottom ranks, colour scales and data bars — and round-trips through Excel' --- -### 🧪 สิ่งที่เทสต์ 1992 เคสจับไม่ได้ +### 🧪 สิ่งที่เทสต์ 2007 เคสจับไม่ได้ เทสต์ของผู้ช่วย 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 ที่มองเห็น เป็นตัวเลขผิดที่มองไม่เห็น** -**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 1992 เทสต์ผ่าน, `axe` ไม่มี violation ทั้งสองหน้า +**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 2007 เทสต์ผ่าน, `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 1992 เคสด้วย Vitest | +| `npm test` | รัน unit test 2007 เคสด้วย Vitest | | `npm run check:readme` | ตรวจว่า README ยังตรงกับโค้ด (ลิงก์/ภาพ/จำนวนเทสต์/โมดูลใหม่/สองภาษาตรงกัน) | | `npm run check:screens` | ตัวเลขที่พิมพ์อยู่บนภาพหน้าจอยังตรงกับซอร์สไหม | | `npm run check:rls` | ยิงจริงสองบัญชีบน Supabase ของคุณ ว่าฐานข้อมูลปฏิเสธตามที่ policy เขียนไว้ไหม (ต้องตั้ง env) | @@ -1055,7 +1055,13 @@ origin ของ endpoint จะถูกใส่ใน `connect-src` ให้ - วางข้อความจากที่อื่นได้ (เช่นจาก Excel จริงหรือ Google Sheets) แยกคอลัมน์/แถวตาม Tab/บรรทัดใหม่ให้เอง พร้อมขยายตารางถ้าข้อมูลใหญ่กว่าที่มีอยู่ - **ตัดข้ามชีตได้** — ตัดจากชีตหนึ่งไปวางอีกชีต ช่องเดิมบนชีตต้นทางถูกล้าง ชีตปลายทางเปลี่ยนเฉพาะตรงที่วาง และ undo ครั้งเดียวคืนทั้งสองชีต - · ถ้าลบชีตต้นทางไปก่อนวาง การวางจะเป็นการคัดลอก ไม่ล้างอะไร · **ยังไม่รองรับ:** สูตรที่อ้างช่องที่ถูกย้ายยังชี้ที่เดิม ไม่ตามไปแบบ Excel + · ถ้าลบชีตต้นทางไปก่อนวาง การวางจะเป็นการคัดลอก ไม่ล้างอะไร +- **ตัดแล้ววางคือการ "ย้าย" แบบ Excel** (#51) — สูตรที่อ้างช่องที่ถูกย้ายตามไปที่ใหม่ ทุกชีตและทุกชื่อช่วง + (`B1` ที่เขียน `=A1*2` กลายเป็น `=D1*2` เมื่อย้าย A1 ไป D1 ค่าเท่าเดิม ไม่ใช่อ่านช่องว่างได้ 0) · + สูตรที่ถูกย้ายเองยังชี้ที่เดิม ไม่เลื่อนแบบตอนคัดลอก (`=A1*2` ย้ายจาก B1 ไป C5 ยังเป็น `=A1*2`) · + ช่วงย้ายตามเมื่อถูกตัดทั้งก้อนเท่านั้น ช่วงที่คร่อมออกนอกก้อนอยู่ที่เดิม · ย้ายไปอีกชีต การอ้างอิงจะมีชื่อชีตกำกับให้เอง · + ย้ายทับตัวเองบางส่วนได้ ช่องที่ทิ้งไว้ถูกล้าง · สูตรที่อ้างเซลล์ปลายทางที่ถูกวางทับกลายเป็น `#REF!` แบบ Excel + (ลองใน Excel จริงแล้ว) · **ยังไม่รองรับ:** ช่วงที่คร่อมเข้าไปในที่วางยังอยู่ที่เดิม และรูปแบบเซลล์ของช่องต้นทางยังอยู่ ไม่ได้ย้ายไปด้วย ### 🎨 จัดรูปแบบเซลล์ @@ -2635,7 +2641,7 @@ stack ของ `font-mono` จึงต่อท้ายด้วย Plex Sans | `@anthropic-ai/sdk` | เชื่อมต่อ Claude API สำหรับผู้ช่วย AI | | `lucide-react` | ไอคอน UI | | `clsx` | รวม className แบบมีเงื่อนไข | -| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (1992 เคส) | +| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (2007 เคส) | > **หมายเหตุ:** ไม่ได้ใช้ไลบรารีคำนวณสูตรสำเร็จรูป (เช่น HyperFormula) แต่เขียน **เอนจินคำนวณสูตรขึ้นเอง** > ทั้ง tokenizer, parser, evaluator และฟังก์ชันต่างๆ เพื่อควบคุมพฤติกรรมได้เต็มที่ ดูรายละเอียดที่หัวข้อ @@ -2878,7 +2884,7 @@ src/ toolbar/LanguageToggle.tsx # ปุ่มสลับภาษา UI lib/ # ตรรกะหลักของโดเมน ไม่ผูกกับ React/UI แก้ไข/ทดสอบแยกจาก UI ได้อิสระ formulaEngine/ # เอนจินคำนวณสูตรที่เขียนเอง — tokenizer.ts, parser.ts, ast.ts, evaluator.ts, - # functions.ts, coerce.ts, address.ts, shift.ts, structuralShift.ts, + # functions.ts, coerce.ts, address.ts, shift.ts, structuralShift.ts, moveRefs.ts, # formulaProgram.ts (แคช AST + หาว่าสูตรอ่านเซลล์ไหนบ้าง) # พร้อม unit test คู่กันทุกไฟล์ (*.test.ts, รันด้วย Vitest) formulaCatalog.ts # โครงสร้างรายการสูตรสำเร็จรูป (id/พารามิเตอร์/วิธีประกอบสูตร) — ชื่อ/คำอธิบาย/ @@ -3130,6 +3136,9 @@ flowchart LR | อ้างอิงชนตำแหน่งที่ถูกลบพอดี | ไม่เกิดกรณีนี้ | กลายเป็น `#REF!` | | ช่วง (range) ที่คร่อมตำแหน่งแทรก/ลบ | เลื่อนทั้งช่วงเท่ากัน | **ขยาย/หด** ตามจริง (แทรกกลางช่วง → ช่วงยาวขึ้น, ลบขอบช่วงจนว่าง → `#REF!`) | +ตัดแล้ววางเป็นกรณีที่สาม (`moveRefs.ts`, #51) และไม่ใช่ทั้งสองแบบข้างบน: สูตรที่ถูกย้ายไม่เลื่อนเลย ส่วนอ้างอิงที่ชี้*เข้า*ก้อนที่ถูกย้าย +ไม่ว่าจะอยู่ชีตไหนหรือในชื่อช่วงไหน ตามไปที่ใหม่รวมถึงแบบ `$A$1` · ช่วงตามไปเมื่อถูกตัดทั้งก้อนเท่านั้น + ### ฟังก์ชันที่รองรับ แถบสูตรที่ลากวางได้แสดงแค่ **37 สูตร** ที่ใช้บ่อยที่สุด แต่ตัวเอนจินจริงรองรับ **66 ฟังก์ชัน** — ที่เหลือพิมพ์ตรง @@ -3539,13 +3548,13 @@ B อ่านและแก้ได้ แต่ยังยึดเป็ ## 🧪 การทดสอบ ```bash -npm test # 1992 เคส ใน 142 ไฟล์ ด้วย Vitest +npm test # 2007 เคส ใน 144 ไฟล์ ด้วย Vitest ``` โฟกัสเทสต์ไปที่ **เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, การแปลง JSON เป็นตาราง, การไล่ดึงหน้าถัดไป, การถอยเมื่อโดน rate limit, แม่แบบจากไฟล์ Excel, กฎจัดรูปแบบตามเงื่อนไข และการวางบล็อกข้อมูลสด** — ส่วนที่เป็น pure function ล้วน ไม่ต้องพึ่ง React/DOM จึงเทสต์ได้เร็วและมั่นใจได้สูง -**แต่ 1992 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์ +**แต่ 2007 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์ ทุกตัวแทบทั้งหมด — ปุ่ม "+ แถว" บนแถบเครื่องมือเรียก `addRow` ที่ไม่ประกาศอะไรเลย ขณะที่ `insertRowAtSelection` ข้าง ๆ ประกาศถูกต้อง (ทั้งคู่เทสต์ผ่าน) · ผู้ช่วย AI ส่งช่วงที่รวมหัวคอลัมน์ไปด้วย เพราะตัวประกอบ context อ่าน `sheet.cells` ที่เป็นสูตรดิบแทนค่าที่คำนวณแล้ว (ทั้งคู่เทสต์ผ่าน) · ปุ่มใหม่หนึ่งปุ่มดันปุ่มสลับภาษาตกขอบจอไป 42px @@ -3645,9 +3654,9 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด ในตารางแล้วสองข้อในflow ที่สามตกทันที (ครั้งแรกที่ลองปิดยังเขียวอยู่ เพราะ `case` ที่แทรกเข้าไปไปอยู่หลัง `case "ArrowRight"` เดิม กลายเป็นโค้ดตาย — การพิสูจน์ต้องเช็กด้วยว่าของที่ตั้งใจทำให้พังนั้นพังจริง) -> **1992 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้ +> **2007 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้ > มันจึงตอบตามที่คนเขียนเทสต์คิดว่ามันจะตอบ จำนวนเทสต์บอกได้แค่ว่าเราถามอะไรไปบ้าง ไม่ได้บอกว่าเราถาม -> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 1992 เคสจับไม่ได้](#-สิ่งที่เทสต์-1992-เคสจับไม่ได้) +> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 2007 เคสจับไม่ได้](#-สิ่งที่เทสต์-2007-เคสจับไม่ได้) > และรันซ้ำเองได้ด้วย `npm run check:ai` | ไฟล์ | เคส | ทดสอบอะไร | @@ -3675,6 +3684,7 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด | `formulaCatalog.test.ts` | 12 | สูตรที่แถบลากวางสร้างออกมาจริง: การใส่เครื่องหมายคำพูดให้เงื่อนไข, เงื่อนไขที่ 2 ที่กรอกไม่ครบ, และทุกสูตรมีคำแปลครบสองภาษา | | `shift.test.ts` | 12 | การเลื่อนอ้างอิงแบบ relative ตอนคัดลอก/วาง, absolute ไม่เลื่อน | | `structuralShift.test.ts` | 16 | การปรับอ้างอิงตอนแทรก/ลบแถว-คอลัมน์ รวม `#REF!` และการขยาย/หดของช่วง | +| `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` + ชื่อเดือนไทย | @@ -3713,6 +3723,7 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด | `store/pivotSize.test.ts` | 6 | ชีตสรุปใหญ่เท่าผลลัพธ์ (#56): 35 สินค้าครบพร้อมแถวรวมตัวหนา, 12 เดือนครบพร้อมคอลัมน์รวม, สรุปใหม่ก็ได้ครบ, ผลเล็กยังได้ชีต 30×10 ที่มีที่ทำงาน, ผลที่ใหญ่เกินชีตไม่สร้างแต่บอกขนาด และสรุปใหม่ที่ใหญ่เกินเตือนโดย pivot เดิมไม่ถูกทับ | | `store/pivotError.test.ts` | 2 | pivot ที่แหล่งข้อมูลมี error (#222): ผลรวม เฉลี่ย ต่ำสุด สูงสุด ของกลุ่มที่มี `#DIV/0!` และแถวรวมขึ้น `#DIV/0!` กลุ่มอื่นได้ตัวเลขเดิม ทั้งตอนสร้างและตอนสรุปใหม่หลังแหล่งข้อมูลมี error | | `store/errorLiterals.test.ts` | 4 | รหัส error ที่อยู่ในเซลล์เป็นค่าคือ error จริง (#226) ทุกทางเข้า: พิมพ์ (`SUM` ได้ `#DIV/0!` · `IFERROR`/`IFNA` จับได้), วางแบบที่ Excel ก๊อป (`#1`, `#N/A please`, `'#N/A` ยังเป็นข้อความ), เปิดจากไฟล์ (เซลล์ `t="e"` เป็น error แต่ string cell ที่เป็น `#N/A` ยังเป็นข้อความ) และ `SUM`/`IFERROR` ที่อ่านชีต pivot ที่มี error | +| `store/cutMove.test.ts` | 8 | ตัดแล้ววางคือการย้ายแบบ Excel (#51): สูตรที่อ่านช่องที่ย้ายตามไปและค่าเท่าเดิม, สูตรที่ถูกย้ายยังชี้ที่เดิม, สูตรที่อ่านช่องที่ถูกวางทับขึ้น `#REF!`, ก้อนที่อ้างตัวเองยังอ้างตัวเองที่ใหม่, ย้ายทับตัวเองบางส่วนล้างช่องที่ทิ้งและเลื่อนอ้างอิงครั้งเดียว, ชื่อช่วงตามไป, ข้ามชีตพร้อม undo ครั้งเดียว, การคัดลอกยังเลื่อนแบบเดิม | | `store/dateEntry.test.ts` | 5 | พิมพ์วันที่แบบคน (#45): `=TODAY()`/`=DATE()`/`=NOW()` ได้รูปแบบวันที่เองถ้าเซลล์ยังเป็น "ทั่วไป", วันที่และเวลาขยายคอลัมน์ที่ยังกว้างเท่าเดิม, เลือกรูปแบบวันที่แทนรูปแบบของไฟล์ | | `store/convertDates.test.ts` | 5 | "แปลงเป็นวันที่" และ "วันที่ (พ.ศ.)" ผ่าน store (#82): แปลงแล้วแสดงปี พ.ศ. ช่องอ่านไม่ออกและสูตรไม่ถูกแตะ, ค.ศ. เดือนก่อน, undo ครั้งเดียวกลับทั้งหมด, รูปแบบที่เลือกไว้ไม่ถูกทับ, รูปแบบ พ.ศ. แสดงและกลับเป็นวันที่ปกติได้ | | `store/sampleNotice.test.ts` | 19 | เปิดมาเป็นตารางว่าง, ข้อมูลตัวอย่างเปิดด้วยปุ่มและ undo ได้, ตัวอย่างที่ค้างในเบราว์เซอร์ (ทั้งสองภาษา) ไม่ถูกคืน แต่แก้แล้วแม้จุดเดียวถือเป็นงาน, ไฟล์ใหม่ undo ได้และประกาศออกเสียง, แถบตัวอย่างอยู่เฉพาะตอนที่ยังไม่แตะ และตัวอย่างตามภาษาบนจอ | diff --git a/public/screenshots/27-landing-stats.png b/public/screenshots/27-landing-stats.png index ae06f82..79e0614 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 d5e7582..3d3d454 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 ee82e18..4d94c95 100644 --- a/public/screenshots/screenshots.json +++ b/public/screenshots/screenshots.json @@ -5,7 +5,7 @@ "27-landing-stats.png": { "paletteFormulas": 37, "engineFunctions": 66, - "tests": 1992, + "tests": 2007, "securityTests": 325 }, "38-landing-problems.png": { @@ -21,7 +21,7 @@ "en/27-landing-stats.png": { "paletteFormulas": 37, "engineFunctions": 66, - "tests": 1992, + "tests": 2007, "securityTests": 325 }, "en/38-landing-problems.png": { diff --git a/public/social-preview.png b/public/social-preview.png index 4a251b9..a649462 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 2a022e8..73a3c6f 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"], - ["1992", "automated tests"], + ["2007", "automated tests"], ["325", "of them security"], ["0", "formula libraries"], ]; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index d22088f..d7b3943 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: "1992", label: "automated tests" }, + { value: "2007", 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 f35c2a6..0ff337f 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: "1992", label: "เทสต์อัตโนมัติ" }, + { value: "2007", label: "เทสต์อัตโนมัติ" }, { value: "325", label: "เทสต์ด้านความปลอดภัย" }, { value: "0", label: "ไลบรารีคำนวณสูตร" }, ], diff --git a/src/lib/formulaEngine/moveRefs.test.ts b/src/lib/formulaEngine/moveRefs.test.ts new file mode 100644 index 0000000..0aad1d3 --- /dev/null +++ b/src/lib/formulaEngine/moveRefs.test.ts @@ -0,0 +1,78 @@ +// Copyright 2026 Suruch Chakrapeesirisuk +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { blockMove, moveFormulaRefs, type CellMove } from "./moveRefs"; + +const here = { readSheet: "Sheet1", writeSheet: "Sheet1" }; +/** A1:B2 on Sheet1, moved so its top-left lands on D5. */ +const move = blockMove("Sheet1", "Sheet1", { row: 0, col: 0 }, { row: 1, col: 1 }, { row: 4, col: 3 }); + +describe("moveFormulaRefs (#51)", () => { + it("moves a reference into the block and keeps its $", () => { + expect(moveFormulaRefs("A1*2", move, here)).toBe("D5*2"); + expect(moveFormulaRefs("$B$2+B$1", move, here)).toBe("$E$6+E$5"); + }); + + it("leaves everything outside the block, and the text of an untouched formula, as it was", () => { + expect(moveFormulaRefs("C1+A3", move, here)).toBe("C1+A3"); + expect(moveFormulaRefs("SUM( C1 , A3 )", move, here)).toBe("SUM( C1 , A3 )"); + expect(moveFormulaRefs('"A1"&A1', move, here)).toBe('"A1"&D5'); + }); + + it("moves a range only when the whole of it was cut", () => { + expect(moveFormulaRefs("SUM(A1:B2)", move, here)).toBe("SUM(D5:E6)"); + expect(moveFormulaRefs("SUM(A1:A2)", move, here)).toBe("SUM(D5:D6)"); + expect(moveFormulaRefs("SUM(A1:A3)", move, here)).toBe("SUM(A1:A3)"); + expect(moveFormulaRefs("SUM(A1:C1)", move, here)).toBe("SUM(A1:C1)"); + }); + + it("follows the block to another sheet, naming it from the sheets it was not written on", () => { + const across = blockMove("Sheet1", "ยอดขาย", { row: 0, col: 0 }, { row: 0, col: 0 }, { row: 0, col: 3 }); + // On the sheet it left: bare A1 meant this sheet, and now has to say where it went. + expect(moveFormulaRefs("A1*2", across, here)).toBe("ยอดขาย!D1*2"); + // On a third sheet, which named it. + expect(moveFormulaRefs("Sheet1!A1+Sheet1!A2", across, { readSheet: "Sheet3", writeSheet: "Sheet3" })).toBe("ยอดขาย!D1+Sheet1!A2"); + // On the sheet it arrived on, which used to have to name it. + expect(moveFormulaRefs("Sheet1!A1", across, { readSheet: "ยอดขาย", writeSheet: "ยอดขาย" })).toBe("ยอดขาย!D1"); + // A bare reference on another sheet means that sheet, and does not move. + expect(moveFormulaRefs("A1", across, { readSheet: "Sheet3", writeSheet: "Sheet3" })).toBe("A1"); + }); + + it("a moved formula keeps pointing at the sheet it came from", () => { + const across = blockMove("Sheet1", "Sheet2", { row: 0, col: 1 }, { row: 0, col: 1 }, { row: 4, col: 2 }); + expect(moveFormulaRefs("A1*2", across, { readSheet: "Sheet1", writeSheet: "Sheet2" })).toBe("Sheet1!A1*2"); + // A reference to itself goes with it, and on its new sheet needs no name. + expect(moveFormulaRefs("B1", across, { readSheet: "Sheet1", writeSheet: "Sheet2" })).toBe("C5"); + }); + + it("a reference to a cell the block landed on becomes #REF!, as in Excel; a range into it stays", () => { + // D1 cut and pasted onto H1, while F1 read H1 (checked in Excel). + const onto = blockMove("Sheet1", "Sheet1", { row: 0, col: 3 }, { row: 0, col: 3 }, { row: 0, col: 7 }); + expect(moveFormulaRefs("H1", onto, here)).toBe("#REF!"); + expect(moveFormulaRefs("Sheet1!H1+1", onto, { readSheet: "Sheet2", writeSheet: "Sheet2" })).toBe("#REF!+1"); + expect(moveFormulaRefs("D1*2", onto, here)).toBe("H1*2"); + expect(moveFormulaRefs("SUM(H1:H3)", onto, here)).toBe("SUM(H1:H3)"); + // H1 on another sheet is not where the block landed. + expect(moveFormulaRefs("H1", onto, { readSheet: "Sheet2", writeSheet: "Sheet2" })).toBe("H1"); + }); + + it("under a filter, only the rows that were cut move, and a range over a hidden row stays", () => { + // Rows 2 and 4 cut (row 3 hidden), pasted at row 10 and 11. + const filtered: CellMove = { + fromSheet: "Sheet1", + toSheet: "Sheet1", + startRow: 1, + startCol: 0, + endRow: 3, + endCol: 0, + rowTo: new Map([ + [1, 9], + [3, 10], + ]), + colOffset: 0, + }; + expect(moveFormulaRefs("A2+A3+A4", filtered, here)).toBe("A10+A3+A11"); + expect(moveFormulaRefs("SUM(A2:A4)", filtered, here)).toBe("SUM(A2:A4)"); + }); +}); diff --git a/src/lib/formulaEngine/moveRefs.ts b/src/lib/formulaEngine/moveRefs.ts new file mode 100644 index 0000000..9a36288 --- /dev/null +++ b/src/lib/formulaEngine/moveRefs.ts @@ -0,0 +1,159 @@ +// Copyright 2026 Suruch Chakrapeesirisuk +// SPDX-License-Identifier: Apache-2.0 + +import { tokenize } from "./tokenizer"; +import { colToLetters, lettersToCol, sheetRefPrefix, splitSheetRef } from "./address"; + +/** + * A block of cells cut from one place and pasted in another (#51). + * + * In Excel a cut and paste is a *move*: every reference to a moved cell follows it, wherever the + * formula is, and the moved formulas themselves keep pointing where they did. A copy is the + * opposite on both counts, which is what this used to be treated as. + */ +export interface CellMove { + /** The sheet the block was cut from. */ + fromSheet: string; + /** The sheet it was pasted into, which may be the same one. */ + toSheet: string; + startRow: number; + startCol: number; + endRow: number; + endCol: number; + /** Where each cut row went, keyed by its row before the move. A row inside the block that is not + * a key was not cut — a filter hid it (#50) — and references to it stay where they are. */ + rowTo: ReadonlyMap; + colOffset: number; +} + +/** The move of a contiguous block, every row going the same distance. */ +export function blockMove( + fromSheet: string, + toSheet: string, + start: { row: number; col: number }, + end: { row: number; col: number }, + to: { row: number; col: number } +): CellMove { + const rowTo = new Map(); + for (let r = start.row; r <= end.row; r++) rowTo.set(r, to.row + r - start.row); + return { fromSheet, toSheet, startRow: start.row, startCol: start.col, endRow: end.row, endCol: end.col, rowTo, colOffset: to.col - start.col }; +} + +/** + * Which sheet a formula's bare references mean, and which sheet it will be written on. + * + * The two are the same for every formula that stays put. They differ for a formula that is itself + * being moved to another sheet: its `A1` meant the sheet it was cut from, and has to go on meaning + * that once it is written somewhere else. + */ +export interface FormulaPlace { + readSheet: string; + writeSheet: string; +} + +const sameSheet = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); +const REF_ERROR = "#REF!"; +const CELL_TOKEN_RE = /^(\$?)([A-Za-z]{1,3})(\$?)(\d+)$/; + +interface CellParts { + colAbs: string; + rowAbs: string; + row: number; + col: number; +} + +function parts(token: string): CellParts | null { + const m = CELL_TOKEN_RE.exec(token); + if (!m) return null; + return { colAbs: m[1], rowAbs: m[3], row: parseInt(m[4], 10) - 1, col: lettersToCol(m[2]) }; +} + +/** Kept `$` and all: a move follows the cell, so an absolute reference moves too. */ +const write = (p: CellParts, row: number, col: number) => `${p.colAbs}${colToLetters(col)}${p.rowAbs}${row + 1}`; + +const insideCols = (move: CellMove, col: number) => col >= move.startCol && col <= move.endCol; + +/** + * The address a reference moves to, or null when it does not move. A range moves only when every + * cell in it was cut, as in Excel; one that reaches outside the block keeps its address. + */ +function movedAddress(ref: string, move: CellMove): string | null { + const [a, b] = ref.split(":"); + const pa = parts(a); + if (!pa) return null; + if (b === undefined) { + const row = move.rowTo.get(pa.row); + if (row === undefined || !insideCols(move, pa.col)) return null; + return write(pa, row, pa.col + move.colOffset); + } + const pb = parts(b); + if (!pb) return null; + const top = Math.min(pa.row, pb.row); + const bottom = Math.max(pa.row, pb.row); + if (!insideCols(move, pa.col) || !insideCols(move, pb.col)) return null; + for (let r = top; r <= bottom; r++) if (!move.rowTo.has(r)) return null; + return `${write(pa, move.rowTo.get(pa.row)!, pa.col + move.colOffset)}:${write(pb, move.rowTo.get(pb.row)!, pb.col + move.colOffset)}`; +} + +/** + * Whether a single-cell reference names a cell the pasted block landed on. In Excel such a + * reference becomes `#REF!`: the cell it meant was replaced by the moved one (checked in Excel, + * #51). A range reaching into the landing area is left alone, as nobody has checked Excel for it. + */ +function landedOn(ref: string, move: CellMove): boolean { + if (ref.includes(":")) return false; + const p = parts(ref); + if (!p) return false; + const col = p.col - move.colOffset; + if (col < move.startCol || col > move.endCol) return false; + for (const to of move.rowTo.values()) if (to === p.row) return true; + return false; +} + +/** One reference token, rewritten for the move and for where its formula will be written. */ +function moveToken(token: string, move: CellMove, place: FormulaPlace): string { + const { sheet: prefix, ref } = splitSheetRef(token); + const target = prefix ?? place.readSheet; + const moved = sameSheet(target, move.fromSheet) ? movedAddress(ref, move) : null; + // Excel drops the sheet name with the reference, since there is no address left to qualify. + if (moved === null && sameSheet(target, move.toSheet) && landedOn(ref, move)) return REF_ERROR; + const sheet = moved === null ? target : move.toSheet; + const address = moved ?? ref; + // Written with a sheet name when it had one, or when the sheet it means is no longer the one + // the formula sits on; bare otherwise, as it was typed. + if (prefix !== null) return moved === null ? token : `${sheetRefPrefix(sheet)}${address}`; + return sameSheet(sheet, place.writeSheet) ? address : `${sheetRefPrefix(sheet)}${address}`; +} + +/** + * Rewrites a formula body (no leading "=") for a cut and paste: references into the moved block + * follow it, and nothing else changes — no copy-style shift. Returns the body unchanged, the same + * string, when there is nothing to move. + */ +export function moveFormulaRefs(body: string, move: CellMove, place: FormulaPlace): string { + let out = ""; + let changed = false; + for (const t of tokenize(body)) { + switch (t.type) { + case "EOF": + break; + case "CELL": + case "RANGE": { + const next = moveToken(t.value, move, place); + if (next !== t.value) changed = true; + out += next; + break; + } + case "STRING": + out += `"${t.value.replace(/"/g, '""')}"`; + break; + case "COMMA": + out += ","; + break; + default: + out += t.value; + } + } + // Re-emitted text drops whitespace, so an untouched formula keeps the text it was typed with. + return changed ? out : body; +} diff --git a/src/lib/sheet.ts b/src/lib/sheet.ts index 6859495..e31fb5b 100644 --- a/src/lib/sheet.ts +++ b/src/lib/sheet.ts @@ -301,6 +301,6 @@ export type { SheetRange } from "./sheetRange"; // Re-exported so `@/lib/sheet` stays the one import surface for sheet operations, even though // clipboard and sort logic live in their own focused modules. export type { ClipboardBlock } from "./sheetClipboard"; -export { copyRange, pasteClipboardBlock, pastePlainTextBlock, clearRange, ensureBounds, toTsv, parseTsv } from "./sheetClipboard"; +export { copyRange, pasteClipboardBlock, placeMovedBlock, pastePlainTextBlock, clearRange, ensureBounds, toTsv, parseTsv } from "./sheetClipboard"; export type { SortRange } from "./sheetSort"; export { detectSortRange, sortRange, sortRisks } from "./sheetSort"; diff --git a/src/lib/sheetClipboard.ts b/src/lib/sheetClipboard.ts index 9d92637..ec4cb6c 100644 --- a/src/lib/sheetClipboard.ts +++ b/src/lib/sheetClipboard.ts @@ -82,6 +82,31 @@ export function pasteClipboardBlock(sheet: SheetModel, clip: ClipboardBlock, tar return next; } +/** + * Writes a cut block at (targetRow, targetCol) for a move (#51). Its formulas are not shifted the + * way a copy's are: each goes through `rewrite`, which moves only the references that point into + * the block itself, so `=A1*2` cut from B1 to C5 still reads A1. + */ +export function placeMovedBlock( + sheet: SheetModel, + clip: ClipboardBlock, + targetRow: number, + targetCol: number, + rewrite: (body: string) => string +): SheetModel { + const height = clip.rows.length; + const width = clip.rows[0]?.length ?? 0; + const next = cloneSheet(ensureBounds(sheet, targetRow + height, targetCol + width)); + for (let r = 0; r < height; r++) { + for (let c = 0; c < width; c++) { + const raw = clip.rows[r][c]; + next.cells[targetRow + r][targetCol + c] = raw.startsWith("=") && raw.length > 1 ? `=${rewrite(raw.slice(1))}` : raw; + next.formats[targetRow + r][targetCol + c] = clip.formats[r]?.[c]; + } + } + return next; +} + /** Pastes plain text (e.g. from the OS clipboard / another spreadsheet) as literal values — * no formula reinterpretation, since we can't tell if "=SUM(...)" from another app is meant * literally or as a formula in ours. */ diff --git a/src/lib/workbookRefs.ts b/src/lib/workbookRefs.ts index 07c8246..b6d6e92 100644 --- a/src/lib/workbookRefs.ts +++ b/src/lib/workbookRefs.ts @@ -17,6 +17,7 @@ import { shiftNames, type NameTable } from "./namedRanges"; import { sheetRefPrefix, splitSheetRef } from "./formulaEngine/address"; import { tokenize } from "./formulaEngine/tokenizer"; import { adjustFormulaForStructuralOp, Axis } from "./formulaEngine/structuralShift"; +import { moveFormulaRefs, type CellMove } from "./formulaEngine/moveRefs"; import { cloneSheet, SheetModel } from "./sheet"; import { cleanSheetName, sheetNameProblem, uniqueSheetName } from "./sheetNames"; @@ -75,6 +76,33 @@ export function shiftOtherSheetsForStructuralOp( }); } +/** + * Points every reference to a cut block at where it was pasted (#51), on every sheet and in every + * name's target. The block's own cells are rewritten here too, as they stand before the paste; the + * caller writes the moved formulas from the clipboard through `moveFormulaRefs` itself, because + * those are read on the sheet they came from and written on the one they go to. + */ +export function moveRefsInWorkbook(tabs: NamedSheet[], move: CellMove): SheetModel[] { + return tabs.map(({ name, sheet }) => { + const place = { readSheet: name, writeSheet: name }; + const moved = mapFormulas(sheet, (body) => moveFormulaRefs(body, move, place)); + const names = moveRefsInNames(moved.names, move, name); + return names === moved.names ? moved : { ...moved, names }; + }); +} + +function moveRefsInNames(table: NameTable | undefined, move: CellMove, sheetName: string): NameTable | undefined { + if (!table) return table; + let changed = false; + const next: NameTable = {}; + for (const [key, entry] of Object.entries(table)) { + const ref = moveFormulaRefs(entry.ref, move, { readSheet: sheetName, writeSheet: sheetName }); + if (ref !== entry.ref) changed = true; + next[key] = ref === entry.ref ? entry : { ...entry, ref }; + } + return changed ? next : table; +} + /** * Rewrites every formula that names `from` so it names `to` instead. * diff --git a/src/store/cutMove.test.ts b/src/store/cutMove.test.ts new file mode 100644 index 0000000..7852de6 --- /dev/null +++ b/src/store/cutMove.test.ts @@ -0,0 +1,118 @@ +// Copyright 2026 Suruch Chakrapeesirisuk +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it } from "vitest"; +import { computeTab, undoSheet, useSheetStore } from "./sheetStore"; + +/** + * Cut and paste is a move (#51). + * + * It was a copy followed by a clear: a formula that read the cut cell went on reading the now + * empty one, and showed 0 without a word, while the cut formula itself was shifted as a copy + * would be. Tested through the store actions Ctrl+X and Ctrl+V call. + */ +const state = () => useSheetStore.getState(); +const tab = (i: number) => state().sheets[i]; +const cells = (i = 0) => tab(i).sheet.cells; +const values = (i = 0) => computeTab(tab(i).sheet, state().sheets).values; +const select = (r1: number, c1: number, r2 = r1, c2 = c1) => + state().setSelection({ startRow: r1, startCol: c1, endRow: r2, endCol: c2, anchorRow: r1, anchorCol: c1 }); +const move = (from: [number, number, number?, number?], to: [number, number]) => { + select(...(from as [number, number, number, number])); + state().cutSelection(); + select(...to); + state().pasteAtSelection(); +}; + +beforeEach(() => { + state().startBlank(); + state().clearClipboard(); + useSheetStore.temporal.getState().clear(); + state().setCellRaw(0, 0, "5"); + state().setCellRaw(0, 1, "=A1*2"); +}); + +describe("cut and paste moves, as in Excel (#51)", () => { + it("a formula that read the moved cell follows it, and keeps its value", () => { + move([0, 0], [0, 3]); + expect(cells()[0][1]).toBe("=D1*2"); + expect(values()[0][1]).toBe(10); + expect(cells()[0][0]).toBe(""); + }); + + it("a moved formula keeps pointing where it did, and what read it follows it", () => { + state().setCellRaw(0, 2, "=B1+1"); + move([0, 1], [4, 2]); + expect(cells()[4][2]).toBe("=A1*2"); + expect(cells()[0][2]).toBe("=C5+1"); + expect(values()[0][2]).toBe(11); + }); + + it("a formula that read the cell the block landed on says #REF!, as Excel does", () => { + // The owner's check in Excel: D1 5, E1 =D1*2, F1 =H1; cut D1, paste on H1 → E1 10, F1 #REF!. + state().setCellRaw(0, 3, "5"); + state().setCellRaw(0, 4, "=D1*2"); + state().setCellRaw(0, 5, "=H1"); + move([0, 3], [0, 7]); + expect(cells()[0][4]).toBe("=H1*2"); + expect(values()[0][4]).toBe(10); + expect(cells()[0][5]).toBe("=#REF!"); + expect(String(values()[0][5])).toBe("#REF!"); + }); + + it("a block that refers to itself still does, at its new place", () => { + state().setCellRaw(1, 0, "=SUM(A1:B1)"); + move([0, 0, 0, 1], [4, 3]); + expect([cells()[4][3], cells()[4][4]]).toEqual(["5", "=D5*2"]); + expect(cells()[1][0]).toBe("=SUM(D5:E5)"); + expect(values()[1][0]).toBe(15); + }); + + it("onto part of itself: what it left is emptied, and references move once", () => { + state().setCellRaw(1, 0, "6"); + state().setCellRaw(2, 0, "7"); + state().setCellRaw(5, 0, "=SUM(A1:A3)"); + move([0, 0, 2, 0], [1, 0]); + expect(cells().slice(0, 4).map((r) => r[0])).toEqual(["", "5", "6", "7"]); + expect(cells()[5][0]).toBe("=SUM(A2:A4)"); + expect(cells()[0][1]).toBe("=A2*2"); + expect(values()[5][0]).toBe(18); + }); + + it("a name that pointed at the cell points at where it went", () => { + select(0, 0); + expect(state().defineName("Rate")).toBeNull(); + state().setCellRaw(2, 0, "=Rate*3"); + move([0, 0], [6, 6]); + expect(tab(0).sheet.names?.RATE.ref).toBe("$G$7"); + expect(values()[2][0]).toBe(15); + }); + + it("across sheets: the sheet it left names the new place, the moved formula names the old sheet, one undo puts it back", () => { + state().setCellRaw(0, 2, "3"); + state().setCellRaw(0, 5, "=B1"); + // B1 reads A1 (moving with it) and C1 (staying). + state().setCellRaw(0, 1, "=A1*C1"); + select(0, 0, 0, 1); + state().cutSelection(); + state().addSheet(); + select(0, 3); + state().pasteAtSelection(); + expect([cells(1)[0][3], cells(1)[0][4]]).toEqual(["5", "=D1*Sheet1!C1"]); + expect(values(1)[0][4]).toBe(15); + expect(cells(0)[0][5]).toBe(`=${tab(1).name}!E1`); + expect(values(0)[0][5]).toBe(15); + undoSheet(); + expect([cells(0)[0][0], cells(0)[0][1], cells(0)[0][5]]).toEqual(["5", "=A1*C1", "=B1"]); + }); + + it("a copy still shifts as a copy, and can be pasted again", () => { + select(0, 1); + state().copySelection(); + select(4, 2); + state().pasteAtSelection(); + expect(cells()[4][2]).toBe("=B5*2"); + expect(cells()[0][1]).toBe("=A1*2"); + expect(state().clipboard).not.toBeNull(); + }); +}); diff --git a/src/store/sheetStore.ts b/src/store/sheetStore.ts index 0281462..e7637ef 100644 --- a/src/store/sheetStore.ts +++ b/src/store/sheetStore.ts @@ -40,6 +40,7 @@ import { insertRowBefore, parseTsv, pasteClipboardBlock, + placeMovedBlock, pastePlainTextBlock, setCellRaw, sheetFromGrid, @@ -61,7 +62,8 @@ import { autoSumRange, hasHeaderRow, headerRow, namedColumns, runIsText, TOTAL_L import { placeFormula, type Cell } from "@/lib/aiPlacement"; import { referencedRects } from "@/lib/precedents"; import { hiddenRowsFor, visibleRowCount } from "@/lib/sheetFilter"; -import { fitSheetNames, renameIncomingToFit, renameSheetInFormulas, shiftOtherSheetsForStructuralOp } from "@/lib/workbookRefs"; +import { fitSheetNames, moveRefsInWorkbook, renameIncomingToFit, renameSheetInFormulas, shiftOtherSheetsForStructuralOp } from "@/lib/workbookRefs"; +import { moveFormulaRefs, type CellMove } from "@/lib/formulaEngine/moveRefs"; import { nextSheetName, SheetNameProblem, sheetNameProblem } from "@/lib/sheetNames"; import { lessonBySlug, lessonSheet } from "@/lib/lessons"; import { fillBlock, fillTargetFor, fillWithin, type FillTarget } from "@/lib/fillSeries"; @@ -1585,50 +1587,53 @@ export const useSheetStore = create()( const targetRow = selection.anchorRow; const targetCol = selection.anchorCol; if (clipboard) { - let next = pasteClipboardBlock(sheet, clipboard, targetRow, targetCol); - let clearedClipboard: ClipboardState | null = clipboard; - let sheets = withActiveSheet(s, () => next); - if (clipboard.cut) { + const from = clipboard.cut ? s.sheets.find((t) => t.id === clipboard.sheetId) : undefined; + let sheets: SheetTab[]; + // A cut is a move (#51): references to the cut cells follow them, on every sheet, and the + // moved formulas keep pointing where they did. If the sheet it was cut from has been + // deleted since, there is nothing left to move from, and it goes down as a copy. + if (from) { const height = clipboard.rows.length; const width = clipboard.rows[0]?.length ?? 0; const srcEndRow = clipboard.sourceRows?.at(-1) ?? clipboard.startRow + height - 1; const srcEndCol = clipboard.startCol + width - 1; - // A cut made under a filter took only the rows on screen (#50); only those are - // emptied, and the hidden rows between them stay as they were. + // A cut made under a filter took only the rows on screen (#50); only those move and + // are emptied, and the hidden rows between them stay as they were. + const cutRows = clipboard.sourceRows ?? Array.from({ length: height }, (_, r) => clipboard.startRow + r); + const cut = new Set(cutRows); const kept = new Set(); - if (clipboard.sourceRows) { - const cut = new Set(clipboard.sourceRows); - for (let r = clipboard.startRow; r <= srcEndRow; r++) if (!cut.has(r)) kept.add(r); - } - const destId = activeTab(s).id; - if (clipboard.sheetId === destId) { - const destOverlapsSource = - targetRow <= srcEndRow && - targetRow + height - 1 >= clipboard.startRow && - targetCol <= srcEndCol && - targetCol + width - 1 >= clipboard.startCol; - // Moving to a spot that overlaps the original block would otherwise wipe out - // the very cells pasteClipboardBlock just wrote there. - if (!destOverlapsSource) { - next = clearRange(next, clipboard.startRow, clipboard.startCol, srcEndRow, srcEndCol, kept); - sheets = withActiveSheet(s, () => next); + for (let r = clipboard.startRow; r <= srcEndRow; r++) if (!cut.has(r)) kept.add(r); + const dest = activeTab(s); + const move: CellMove = { + fromSheet: from.name, + toSheet: dest.name, + startRow: clipboard.startRow, + startCol: clipboard.startCol, + endRow: srcEndRow, + endCol: srcEndCol, + rowTo: new Map(cutRows.map((r, i) => [r, targetRow + i])), + colOffset: targetCol - clipboard.startCol, + }; + const moved = moveRefsInWorkbook(s.sheets, move); + const place = { readSheet: from.name, writeSheet: dest.name }; + // Emptied first and written second, so a block moved onto part of itself keeps what + // lands there and loses only what it left. Every sheet changes in one update, so one + // undo puts the whole move back. + sheets = s.sheets.map((t, i) => { + let next = moved[i]; + if (t.id === from.id) next = clearRange(next, clipboard.startRow, clipboard.startCol, srcEndRow, srcEndCol, kept); + if (t.id === dest.id) { + next = placeMovedBlock(next, clipboard, targetRow, targetCol, (body) => moveFormulaRefs(body, move, place)); } - } else { - // Cleared on the sheet it was cut from. If that sheet has been deleted since, - // there is nothing left to clear, and this is a copy: nothing on the sheet being - // pasted into is touched beyond the block itself. Both sheets change in one - // update, so one undo puts both back. - sheets = sheets.map((t) => - t.id === clipboard.sheetId - ? { ...t, sheet: clearRange(t.sheet, clipboard.startRow, clipboard.startCol, srcEndRow, srcEndCol, kept) } - : t - ); - } - clearedClipboard = null; + return next === t.sheet ? t : { ...t, sheet: next }; + }); + } else { + sheets = withActiveSheet(s, () => pasteClipboardBlock(sheet, clipboard, targetRow, targetCol)); } return { sheets, - clipboard: clearedClipboard, + // A cut is pasted once, as in Excel; a copy can go down again. + clipboard: clipboard.cut ? null : clipboard, pasteWarning: null, ...say( getMessages().live.pasted(