diff --git a/AGENTS.md b/AGENTS.md index fe1c81c..01b5823 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,11 +46,11 @@ formula palette, AI, live data, conditional formatting, charts, pivots, find/rep dialog, the validation, names and comment popovers, the alert shown when a save is refused, the fill swatches, the dialog asked when a file would open on top of work, the one asked before New file, the one asked before a sort that would get a formula wrong, the report of what an opened file cannot keep, a sheet-name editor saying why Excel would refuse the name, the one asked before a paste over rows a filter hides, the one a second tab on the same workbook asks and the notice on a tab that is only looking, the AI assistant's answer card and the one that declines and points at a form, the notice on a tab that edits again because the other closed, the rescue screen after a crash, the Convert to dates dialog, the sample's notice once it is opened, the cell menu, a landing problem card unfolded, the two live-data dialogs (the picker and adding a source), the connect-from-this-browser form and the checklist for IT it shows when a connection fails, the note under a live-data cell that was typed over, and five things only a phone shows (the menu, cell tools, the keys bar while a formula is typed, the picking bar after ⌖ and the "tap again to type" label after one tap, scanned at 390px only with a touch screen), the save status's popover, the toast after a file opens, and two short screens (a phone on its side at 844×390, and 200% zoom at 195×422, which also fails on sideways scroll), across `/`, `/app`, `/guide`, the 404 page and the formula pages (`/formulas` and `/formulas/sumif`) — 121 checks in all. **A new panel means a new entry in `OPENED_STATES`**, and a state that will not open fails the gate rather than being skipped. -`npm run check:e2e` ขับแอปจริงในเบราว์เซอร์ 59 flow — วางทับบล็อกข้อมูลสดแล้วถูกปฏิเสธทั้งก้อนพร้อมป้าย และรีเฟรชไม่ลบอะไร, พิมพ์ทับเซลล์ข้อมูลสดแล้วมีป้ายใต้เซลล์บอกเหตุผลและ Enter ไม่พาเคอร์เซอร์หนีไปก่อนอ่าน, เปิดแผงถาม AI แล้วสิ่งที่พิมพ์ลงช่องคำถามไม่ลงเซลล์ที่ถูกบัง และ Esc ปิดแผงแล้ว focus กลับเซลล์เดิม, บนมือถือแตะเซลล์สูตรครั้งที่สองแล้วช่องแก้ไขเปิดพร้อมสูตรครบ และสิ่งที่พิมพ์ไม่ไปทับเซลล์สองแถวล่าง แตะเซลล์อื่นแล้วเลือกแค่เซลล์นั้น, บนมือถือพิมพ์หลัง Enter ลงเซลล์ที่มีค่าแล้วแทนที่ค่าเดิม ไม่ต่อท้าย และแตะซ้ำยังแก้ต่อท้ายได้, บนมือถือหลัง Enter ในแถบสูตรแตะเซลล์แล้วเลือกเซลล์นั้น ไม่ต่อที่อยู่ลงสูตรของเซลล์ถัดไป และสูตรที่แค่เปิดดูไม่ถูกชี้ใส่, พิมพ์ C02 ลงช่อง dropdown แล้วได้ C02 ไม่ใช่ตัวแรกที่ขึ้นต้นด้วย C ค่านอกรายการถูกปฏิเสธและบอกเหตุผล และเลือกจากรายการยังบันทึกได้ ทั้งที่ 1280 และ 390, เปิดไฟล์จริงที่มีรูป กราฟ และฟังก์ชันที่เอนจินไม่มีแล้วแอปบอกก่อนว่าจะหาย ปิดแล้วเปิดดูจากเมนูได้ ไฟล์ธรรมดาไม่เห็นอะไร และชื่อช่วงที่นำเข้าไม่ได้อยู่ในรายงานเดียวกัน ไม่ใช่ alert แยก, แหล่งข้อมูลสดที่ตอบหลังหน้าต่างเลือกข้อมูลเปิดไม่ทำให้เซลล์กลายเป็นช่องว่าง, หน้าสูตรกด "ลองในตาราง" แล้วค่าในเซลล์ตรงกับบนหน้า ชื่อบทเรียนหายจาก URL และบนงานที่มีอยู่ถามก่อนโดยตัวเลือกที่ focus เก็บงานไว้, งานเก่าที่ชื่อชีตซ้ำหรือผิดกติกา Excel เปิดมาแล้วชื่อถูกและสูตรชี้แท็บที่ถูก ตั้งชื่อผิดกติกาแล้วถูกปฏิเสธตรงที่พิมพ์ และส่งออกได้ไฟล์ที่ชื่อชีตกับสูตรถูกต้อง, ปิดแท็บที่แก้อยู่แล้วแท็บที่ดูอยู่แก้ต่อได้เองพร้อมงานครบ, แอปพังแล้วหน้ากู้งานขึ้น ไฟล์ที่โหลดมีงานครบ และลองอีกครั้งกลับมาที่ตารางเดิม, วันที่ พ.ศ. นับได้จริงและคำสั่งแปลงเป็นวันที่จากเมนูคลิกขวาแสดงตัวอย่างและ undo ครั้งเดียวกลับ, ผู้ช่วย AI ใส่ยอดรวมใต้คอลัมน์ไม่ทับสูตรเดิม และคำถามที่มีเงื่อนไขชี้ไปฟอร์ม SUMIF, ลากเลือกช่วงบนมือถือไม่เลยคอลัมน์และแตะระหว่างพิมพ์ `=` ไม่บันทึกทับ, แตะครั้งเดียวบนมือถือแค่เลือก ไม่มีคีย์บอร์ดค้าง เซลล์บอก "แตะอีกครั้งเพื่อพิมพ์" และสิ่งที่พิมพ์ไม่หายเงียบ, Enter หรือ ↓ ที่แถวสุดท้ายเพิ่มแถวโดยไม่มีค่าไหนถูกทับและ undo ครั้งเดียวเอาค่ากับแถวออกแล้วพิมพ์ต่อได้, บนมือถือ undo ตอนช่องแก้ไขเปิดอยู่บนแถวที่ Enter งอกขึ้นมาแล้วช่องปิด บอกว่าทิ้งข้อความที่พิมพ์ค้าง และค่าถัดไปพิมพ์ลงได้, แท็บที่สองบนงานเดียวกันถามก่อน ขอใช้แล้วแท็บเดิมดูอย่างเดียวและไม่มีงานหาย, วันที่ในคอลัมน์กว้างค่าตั้งต้นของ Excel ขึ้นเป็นวันที่ไม่ใช่ `###` ทั้งที่ 1280 และ 390, ชีตชื่อไทยดาวน์โหลดเป็นไฟล์ชื่อไทย, เรียงชีตตัวอย่างแล้วทุกแถวยังคูณของตัวเองและการเรียงที่สูตรจะผิดถามก่อน, กรองแล้วกด Delete บนช่วงที่เห็นแถวที่ซ่อนไม่หาย และวางทับแถวที่ซ่อนแล้วถามก่อน, เปอร์เซ็นต์ที่บันทึกไว้ก่อน #53 ยังขึ้นเหมือนเดิมหลัง migrate, เปิดมาเป็นตารางว่างและข้อมูลตัวอย่างที่ค้างในเบราว์เซอร์ไม่กลับมา (แต่ถ้าแก้แล้วต้องอยู่) ไฟล์ใหม่ถามก่อนและ Ctrl+Z เอางานคืน, พิมพ์สูตรแล้วดูค่าขยับ, ส่งออก `.xlsx` แล้วนำกลับเข้ามา, เปิดไฟล์ทับงานแล้วแอปถามก่อน (ตัวเลือกที่ focus อยู่ต้องเก็บงานไว้ และ undo ถอนไฟล์ออกได้) พร้อม `Ctrl+B`, Tab ตามแถวแล้ว Enter กลับคอลัมน์แรก กับเมนูเซลล์จาก `Shift+F10`, ลากคอลัมน์ให้กว้างแล้ว undo ครั้งเดียวกลับ, Enter บนมือถือเข้าช่องถัดไปโดยคีย์บอร์ดไม่พับ, +`npm run check:e2e` ขับแอปจริงในเบราว์เซอร์ 60 flow — เรียงข้อมูลข้างบล็อกข้อมูลสดแล้วถูกปฏิเสธและโน้ตไม่ย้ายแถวหลังรีเฟรช, วางทับบล็อกข้อมูลสดแล้วถูกปฏิเสธทั้งก้อนพร้อมป้าย และรีเฟรชไม่ลบอะไร, พิมพ์ทับเซลล์ข้อมูลสดแล้วมีป้ายใต้เซลล์บอกเหตุผลและ Enter ไม่พาเคอร์เซอร์หนีไปก่อนอ่าน, เปิดแผงถาม AI แล้วสิ่งที่พิมพ์ลงช่องคำถามไม่ลงเซลล์ที่ถูกบัง และ Esc ปิดแผงแล้ว focus กลับเซลล์เดิม, บนมือถือแตะเซลล์สูตรครั้งที่สองแล้วช่องแก้ไขเปิดพร้อมสูตรครบ และสิ่งที่พิมพ์ไม่ไปทับเซลล์สองแถวล่าง แตะเซลล์อื่นแล้วเลือกแค่เซลล์นั้น, บนมือถือพิมพ์หลัง Enter ลงเซลล์ที่มีค่าแล้วแทนที่ค่าเดิม ไม่ต่อท้าย และแตะซ้ำยังแก้ต่อท้ายได้, บนมือถือหลัง Enter ในแถบสูตรแตะเซลล์แล้วเลือกเซลล์นั้น ไม่ต่อที่อยู่ลงสูตรของเซลล์ถัดไป และสูตรที่แค่เปิดดูไม่ถูกชี้ใส่, พิมพ์ C02 ลงช่อง dropdown แล้วได้ C02 ไม่ใช่ตัวแรกที่ขึ้นต้นด้วย C ค่านอกรายการถูกปฏิเสธและบอกเหตุผล และเลือกจากรายการยังบันทึกได้ ทั้งที่ 1280 และ 390, เปิดไฟล์จริงที่มีรูป กราฟ และฟังก์ชันที่เอนจินไม่มีแล้วแอปบอกก่อนว่าจะหาย ปิดแล้วเปิดดูจากเมนูได้ ไฟล์ธรรมดาไม่เห็นอะไร และชื่อช่วงที่นำเข้าไม่ได้อยู่ในรายงานเดียวกัน ไม่ใช่ alert แยก, แหล่งข้อมูลสดที่ตอบหลังหน้าต่างเลือกข้อมูลเปิดไม่ทำให้เซลล์กลายเป็นช่องว่าง, หน้าสูตรกด "ลองในตาราง" แล้วค่าในเซลล์ตรงกับบนหน้า ชื่อบทเรียนหายจาก URL และบนงานที่มีอยู่ถามก่อนโดยตัวเลือกที่ focus เก็บงานไว้, งานเก่าที่ชื่อชีตซ้ำหรือผิดกติกา Excel เปิดมาแล้วชื่อถูกและสูตรชี้แท็บที่ถูก ตั้งชื่อผิดกติกาแล้วถูกปฏิเสธตรงที่พิมพ์ และส่งออกได้ไฟล์ที่ชื่อชีตกับสูตรถูกต้อง, ปิดแท็บที่แก้อยู่แล้วแท็บที่ดูอยู่แก้ต่อได้เองพร้อมงานครบ, แอปพังแล้วหน้ากู้งานขึ้น ไฟล์ที่โหลดมีงานครบ และลองอีกครั้งกลับมาที่ตารางเดิม, วันที่ พ.ศ. นับได้จริงและคำสั่งแปลงเป็นวันที่จากเมนูคลิกขวาแสดงตัวอย่างและ undo ครั้งเดียวกลับ, ผู้ช่วย AI ใส่ยอดรวมใต้คอลัมน์ไม่ทับสูตรเดิม และคำถามที่มีเงื่อนไขชี้ไปฟอร์ม SUMIF, ลากเลือกช่วงบนมือถือไม่เลยคอลัมน์และแตะระหว่างพิมพ์ `=` ไม่บันทึกทับ, แตะครั้งเดียวบนมือถือแค่เลือก ไม่มีคีย์บอร์ดค้าง เซลล์บอก "แตะอีกครั้งเพื่อพิมพ์" และสิ่งที่พิมพ์ไม่หายเงียบ, Enter หรือ ↓ ที่แถวสุดท้ายเพิ่มแถวโดยไม่มีค่าไหนถูกทับและ undo ครั้งเดียวเอาค่ากับแถวออกแล้วพิมพ์ต่อได้, บนมือถือ undo ตอนช่องแก้ไขเปิดอยู่บนแถวที่ Enter งอกขึ้นมาแล้วช่องปิด บอกว่าทิ้งข้อความที่พิมพ์ค้าง และค่าถัดไปพิมพ์ลงได้, แท็บที่สองบนงานเดียวกันถามก่อน ขอใช้แล้วแท็บเดิมดูอย่างเดียวและไม่มีงานหาย, วันที่ในคอลัมน์กว้างค่าตั้งต้นของ Excel ขึ้นเป็นวันที่ไม่ใช่ `###` ทั้งที่ 1280 และ 390, ชีตชื่อไทยดาวน์โหลดเป็นไฟล์ชื่อไทย, เรียงชีตตัวอย่างแล้วทุกแถวยังคูณของตัวเองและการเรียงที่สูตรจะผิดถามก่อน, กรองแล้วกด Delete บนช่วงที่เห็นแถวที่ซ่อนไม่หาย และวางทับแถวที่ซ่อนแล้วถามก่อน, เปอร์เซ็นต์ที่บันทึกไว้ก่อน #53 ยังขึ้นเหมือนเดิมหลัง migrate, เปิดมาเป็นตารางว่างและข้อมูลตัวอย่างที่ค้างในเบราว์เซอร์ไม่กลับมา (แต่ถ้าแก้แล้วต้องอยู่) ไฟล์ใหม่ถามก่อนและ Ctrl+Z เอางานคืน, พิมพ์สูตรแล้วดูค่าขยับ, ส่งออก `.xlsx` แล้วนำกลับเข้ามา, เปิดไฟล์ทับงานแล้วแอปถามก่อน (ตัวเลือกที่ focus อยู่ต้องเก็บงานไว้ และ undo ถอนไฟล์ออกได้) พร้อม `Ctrl+B`, Tab ตามแถวแล้ว Enter กลับคอลัมน์แรก กับเมนูเซลล์จาก `Shift+F10`, ลากคอลัมน์ให้กว้างแล้ว undo ครั้งเดียวกลับ, Enter บนมือถือเข้าช่องถัดไปโดยคีย์บอร์ดไม่พับ, เดินด้วยคีย์บอร์ดล้วน, วางตัวเลขจาก clipboard แบบที่ Excel ก๊อปออกมา (`1,250` `15%` `฿1,234.50`) แล้ว SUM ถูกและรหัสยังเป็นข้อความ, undo, แก้ต่อหลัง undo การเพิ่มชีต, ตัดข้ามชีต, แถบสูตรไม่เขียนค่าค้าง, "เปลี่ยนอะไรไกลจากเคอร์เซอร์แล้วพูดออกมาไหม", ผู้ช่วย AI (stub route ไว้ ทั้งกรณีตอบปกติและกรณีโดน rate limit) CSP (header มาจริง, ยิงออกนอก policy ไม่ได้, แอปเองไม่สะดุด) บันทึกไม่ลงแล้วแอปบอกและยังส่งออกได้, ต่อ API จากเบราว์เซอร์แล้วค่าลงตารางและรีเฟรชเอง โดยไม่มีอะไรเกี่ยวกับมันถึง `/api/*` (และ CSP ของคนที่ไม่เคยเพิ่มแหล่งยังเหมือนเดิมทุกตัวอักษร · แท็บใหม่ที่ไม่มีค่า header ขึ้นว่ารอค่า ไม่ใช่เชื่อมต่อไม่ได้), API ที่ไม่ตอบ CORS ได้รายการตรวจสำหรับ IT ไม่ใช่ error เปล่า, ไม่มีหน้าไหนเรียกตัวเองว่าเดโมทั้งสองภาษา, ทุกหน้าใน sitemap ประกาศ canonical ของตัวเองและหน้าแรกมี JSON-LD ที่ถือ nonce โดยคำถามที่พบบ่อยใน JSON-LD ตรงกับบนหน้าคำต่อคำ และที่ 390px ทางไปข้อมูลสดอยู่บนจอและมีชื่อ บนมือถือการแตะระหว่างพิมพ์ `=` ไม่บันทึกสูตรครึ่งเดียว แต่ใส่ที่อยู่เซลล์ลงสูตร ลากขยายเป็นช่วงได้ และ ⌖ เลือกช่วงจากตารางได้ หลังเปิดไฟล์บนมือถือข้อความขึ้นทีละอัน ตารางเหลือ 12 แถวขึ้นไปและปัดข้างได้ มือถือแนวนอนเห็นตาราง 6 แถวขึ้นไปและกดค้างเปิดเมนูเซลล์ และค่าข้ามชีตบนจอยังถูกหลังส่งออก PDF, sort และ undo เกณฑ์เลือก flow มีข้อเดียว: **unit test จับได้อยู่แล้วหรือเปล่า** ถ้าจับได้ ไม่ต้องอยู่ที่นี่ ที่เหลือคือรอยต่อ ซึ่งเป็นที่ที่บั๊กของโปรเจกต์นี้อยู่ทุกตัว -`npm run check:e2e` drives the real app in a browser through 59 flows — a paste into a live block refused whole with the note and nothing for a refresh to wipe, typing over a live-data cell getting a note under it saying why, with Enter not taking the cursor away before it is read, opening Ask AI putting what is typed in the question box rather than in the cell it covers, with Escape closing it back to that cell, on a phone, a second tap on a formula cell opening its editor with the formula whole and typing landing there rather than two rows down and a tap away selecting one cell, on a phone, typing after Enter into a filled cell replacing its value rather than going on the end, with a second tap still editing in place, on a phone, a tap after Enter in the formula bar selecting the cell rather than pointing into the next cell's formula, with a formula only opened to look at left alone, C02 typed into a dropdown cell saving C02 rather than the first option starting with C, text off the list refused out loud and a pick from the list still saving, at 1280 and 390, a real file with a picture, a chart and a function the engine lacks saying what it will lose as it opens, reopened from the menu, with nothing for a plain file, and range names that could not come in listed in the same report rather than a separate alert, a live source answering after the picker opens never blanking the cell, a formula page's "try it in the sheet" landing the page's own numbers in the sheet, leaving the address clean and, over work, asking first with keeping the work focused, an old save with duplicate or invalid sheet names opening with names Excel takes and formulas on the right tab, a bad name refused where it is typed, and an export whose sheet names and formulas are right, a view-only tab editing by itself with all the work once the editing tab closes, a crash that brings up the rescue screen, whose file holds the work and whose Try again comes back to the sheet, a Buddhist-Era date that counts and Convert to dates from the cell menu, previewed and undone in one step, the AI assistant putting a column's total under it rather than over a formula and sending a condition to the SUMIF form, a range dragged on a phone staying in its column and a tap during `=` saving nothing, one tap on a phone only selecting, with no keyboard left up, the cell saying "tap again to type" and nothing typed lost in silence, Enter or ↓ on the last row adding a row with nothing typed over and one undo taking the value and its row back with typing still landing after it, on a phone an undo with an editor open on a row Enter grew closing the editor, saying what it dropped, and the next value landing, a second tab on the same workbook asking first, with taking over turning the first view-only and no edit lost, dates in Excel's default column width showing as dates, not `###`, at 1280 and 390, a Thai sheet name downloading as a Thai file name, the sample sorted with every row still its own and a risky sort asking first, Delete on a filtered range leaving the hidden rows alone and a paste over them asking first, a percent saved before #53 still reading the same after the migration, opening blank, with a sample an older version saved not coming back (and one edited, staying), New file asking first and Ctrl+Z bringing the work back, type a formula and watch the value +`npm run check:e2e` drives the real app in a browser through 60 flows — a sort beside a live block refused with every note still on its row after a refresh, a paste into a live block refused whole with the note and nothing for a refresh to wipe, typing over a live-data cell getting a note under it saying why, with Enter not taking the cursor away before it is read, opening Ask AI putting what is typed in the question box rather than in the cell it covers, with Escape closing it back to that cell, on a phone, a second tap on a formula cell opening its editor with the formula whole and typing landing there rather than two rows down and a tap away selecting one cell, on a phone, typing after Enter into a filled cell replacing its value rather than going on the end, with a second tap still editing in place, on a phone, a tap after Enter in the formula bar selecting the cell rather than pointing into the next cell's formula, with a formula only opened to look at left alone, C02 typed into a dropdown cell saving C02 rather than the first option starting with C, text off the list refused out loud and a pick from the list still saving, at 1280 and 390, a real file with a picture, a chart and a function the engine lacks saying what it will lose as it opens, reopened from the menu, with nothing for a plain file, and range names that could not come in listed in the same report rather than a separate alert, a live source answering after the picker opens never blanking the cell, a formula page's "try it in the sheet" landing the page's own numbers in the sheet, leaving the address clean and, over work, asking first with keeping the work focused, an old save with duplicate or invalid sheet names opening with names Excel takes and formulas on the right tab, a bad name refused where it is typed, and an export whose sheet names and formulas are right, a view-only tab editing by itself with all the work once the editing tab closes, a crash that brings up the rescue screen, whose file holds the work and whose Try again comes back to the sheet, a Buddhist-Era date that counts and Convert to dates from the cell menu, previewed and undone in one step, the AI assistant putting a column's total under it rather than over a formula and sending a condition to the SUMIF form, a range dragged on a phone staying in its column and a tap during `=` saving nothing, one tap on a phone only selecting, with no keyboard left up, the cell saying "tap again to type" and nothing typed lost in silence, Enter or ↓ on the last row adding a row with nothing typed over and one undo taking the value and its row back with typing still landing after it, on a phone an undo with an editor open on a row Enter grew closing the editor, saying what it dropped, and the next value landing, a second tab on the same workbook asking first, with taking over turning the first view-only and no edit lost, dates in Excel's default column width showing as dates, not `###`, at 1280 and 390, a Thai sheet name downloading as a Thai file name, the sample sorted with every row still its own and a risky sort asking first, Delete on a filtered range leaving the hidden rows alone and a paste over them asking first, a percent saved before #53 still reading the same after the migration, opening blank, with a sample an older version saved not coming back (and one edited, staying), New file asking first and Ctrl+Z bringing the work back, type a formula and watch the value move, export `.xlsx` and import it back, a file opened on top of work that asks first (the focused choice keeps the work, and undo takes the file back out) along with `Ctrl+B`, a row typed with Tab ending with Enter under its first column and the cell menu from `Shift+F10`, a column dragged wider that one undo puts back, Enter on a phone going into the next cell's editor, keyboard only, numbers pasted through the real clipboard as Excel copies them (`1,250` `15%` `฿1,234.50`) adding up while codes stay text, undo, editing after undoing a new sheet, a cut across sheets, a formula bar that never writes a stale value, whether a change away from the cursor is announced, the AI assistant with its route stubbed (both a normal answer and a rate limit), and the CSP (served, blocking exfiltration, and not tripping the app up), a refused save that is announced while export still works, an API connected from this browser that fills the sheet and refreshes with nothing about it reaching `/api/*` (and the CSP unchanged, character for character, for anyone who never added a source; a new tab without the header value says it is waiting, not failing), an API without CORS getting a checklist for IT rather than a bare error, no page calling itself a demo in either language, every page in the sitemap declaring its own canonical with the landing page's JSON-LD carrying the nonce and its FAQ matching the page word for word, at 390px whether the way to live data is on screen and named, on a phone a tap during `=` putting the cell into the formula (widened by a drag) rather than saving half of it, and ⌖ picking a range from the grid, a file opened on a phone leaving one message at a time with twelve rows and a working sideways swipe, a phone on its side keeping six rows with a long press opening the cell menu, and cross-sheet values still right on screen after a PDF export, a sort and its undo. Flows are picked by one rule: **would a unit test already catch it?** If yes it does not belong diff --git a/README.en.md b/README.en.md index 63540bd..a7ffbe0 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), 2007 automated tests. +an undo that does not erase the other person's work. Bilingual UI (Thai/English), 2010 automated tests. --- @@ -109,7 +109,7 @@ Want the harder parts: [embedding a Thai font in the PDF, with stacked tone mark --- -### 🧪 What 2007 passing tests could not catch +### 🧪 What 2010 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 — 2007 tests, `axe` clean on +**And it happened again, in a different place.** With every gate green — 2010 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 2007-case Vitest suite | +| `npm test` | Run the 2010-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) | @@ -403,7 +403,7 @@ Other available commands: | `npm run check:bundle` | Size budgets, and the cloud client staying in a chunk of its own (needs a build) | | `npm run check:mutants` | Breaks the engine on purpose and checks the suite notices — 32/32 (no build needed) | | `npm run check:a11y` | axe on both pages at 390px and 1280px, plus sideways-scroll checks (needs a build) · `A11Y_WIDTH=390` runs one half, which is how CI runs it | -| `npm run check:e2e` | Drives the real app through 59 flows: Ctrl+V into a live cell writing nothing, saying nothing about a paste, putting the live-data note under the cell, and a refresh changing nothing, with typing on a range that starts on a live cell answered too (#232), `999` and Enter on a live-data cell getting a note under the cell saying why, Enter putting the note away with the cursor and the value where they were, and Delete and a double-click saying so too (#207), opening Ask AI putting what is typed in the question box rather than in the cell it covers, and Escape closing it with focus back on that cell, at 1280 and 390 (#203), on a phone, a second tap on a formula cell opening its editor with the formula whole, what is typed going into that formula and the cell two rows down left alone (#201), and a tap on another cell with nothing typed selecting that cell alone rather than a range (#210), on a phone, typing after Enter into a filled cell replacing its value rather than going on the end (#202), on a phone, a tap after Enter in the formula bar selecting the cell rather than breaking the next cell's formula, and a formula only opened to look at left alone (#201), typing into a dropdown cell saving what was typed in full, text off the list refused and a pick from the list still saving, at 1280 and 390 (#198), a file with a picture and a chart saying what it will lose as it opens, a plain one saying nothing, and range names that could not come in in the same report (#83), a live source that answers after the picker opens never blanking the cell (#126), one tap on a phone only selecting and saying to tap again, with nothing typed lost in silence (#171), Enter or ↓ on the last row adding a row with nothing typed over and one undo taking the value and its row back with typing still landing after it (#170), on a phone an undo with an editor open on a row Enter grew closing the editor, saying what it dropped, and the next value landing (#191), a formula page's try-it button landing the page's own numbers in the sheet, and asking first over work (#149), numbers pasted through the real clipboard as Excel copies them adding up while codes stay text (#52), the AI assistant putting a column's total under it and sending a condition to the SUMIF form (#62–#64), a view-only tab editing by itself, with all the work, once the editing tab closes (#146), a crash bringing up the rescue screen, whose file holds the work and whose Try again comes back (#145), a Buddhist-Era date that counts and Convert to dates from the cell menu (#82), duplicate or invalid sheet names fixed on load, refused when typed, and exported with the right names and formulas (#54), a second tab asking first, and taking over turning the first view-only with no edit lost (#47), dates in Excel's default column width showing as dates rather than `###` (1280 and 390), a Thai sheet name downloading as a Thai file name, the sample sorted in both languages with every row still multiplying its own numbers and a risky sort asking first, a percent saved before #53 reading the same, opening blank with a sample left in the browser not coming back, New file asking first and undoing, formulas, `.xlsx` round trip, an import on top of work that asks first, Tab then Enter and the cell menu, dragging a column's width, Enter on a phone going into the next cell, a range dragged on a phone staying in its column and a tap during `=` saving nothing, tapped cells going into a formula that Done saves with its bracket closed, ⌖ on a phone picking from the grid, a file opened on a phone leaving one message at a time, twelve rows or more and a sideways swipe that works, with "saved in this browser" moved to the save status, a phone on its side folding the bars into two rows with six rows of grid and a long press opening the cell menu, keyboard only, undo, editing after undoing a new sheet, a cut across sheets, a formula bar that never writes a stale value, announcements, the AI assistant, the CSP, an API connected from the browser (nothing reaching `/api/*`), an API without CORS getting the IT checklist, no page calling itself a demo, every page in the sitemap naming its own canonical with the landing page carrying the Search Console tag, the phone tab bar, cross-sheet values on screen after a PDF export, a sort and undo (needs a build · `E2E_ONLY=` runs just the matching flows while editing) | +| `npm run check:e2e` | Drives the real app through 60 flows: a sort beside a live block refused with the note, no row moving, and every note still on its own product after a refresh (#235), Ctrl+V into a live cell writing nothing, saying nothing about a paste, putting the live-data note under the cell, and a refresh changing nothing, with typing on a range that starts on a live cell answered too (#232), `999` and Enter on a live-data cell getting a note under the cell saying why, Enter putting the note away with the cursor and the value where they were, and Delete and a double-click saying so too (#207), opening Ask AI putting what is typed in the question box rather than in the cell it covers, and Escape closing it with focus back on that cell, at 1280 and 390 (#203), on a phone, a second tap on a formula cell opening its editor with the formula whole, what is typed going into that formula and the cell two rows down left alone (#201), and a tap on another cell with nothing typed selecting that cell alone rather than a range (#210), on a phone, typing after Enter into a filled cell replacing its value rather than going on the end (#202), on a phone, a tap after Enter in the formula bar selecting the cell rather than breaking the next cell's formula, and a formula only opened to look at left alone (#201), typing into a dropdown cell saving what was typed in full, text off the list refused and a pick from the list still saving, at 1280 and 390 (#198), a file with a picture and a chart saying what it will lose as it opens, a plain one saying nothing, and range names that could not come in in the same report (#83), a live source that answers after the picker opens never blanking the cell (#126), one tap on a phone only selecting and saying to tap again, with nothing typed lost in silence (#171), Enter or ↓ on the last row adding a row with nothing typed over and one undo taking the value and its row back with typing still landing after it (#170), on a phone an undo with an editor open on a row Enter grew closing the editor, saying what it dropped, and the next value landing (#191), a formula page's try-it button landing the page's own numbers in the sheet, and asking first over work (#149), numbers pasted through the real clipboard as Excel copies them adding up while codes stay text (#52), the AI assistant putting a column's total under it and sending a condition to the SUMIF form (#62–#64), a view-only tab editing by itself, with all the work, once the editing tab closes (#146), a crash bringing up the rescue screen, whose file holds the work and whose Try again comes back (#145), a Buddhist-Era date that counts and Convert to dates from the cell menu (#82), duplicate or invalid sheet names fixed on load, refused when typed, and exported with the right names and formulas (#54), a second tab asking first, and taking over turning the first view-only with no edit lost (#47), dates in Excel's default column width showing as dates rather than `###` (1280 and 390), a Thai sheet name downloading as a Thai file name, the sample sorted in both languages with every row still multiplying its own numbers and a risky sort asking first, a percent saved before #53 reading the same, opening blank with a sample left in the browser not coming back, New file asking first and undoing, formulas, `.xlsx` round trip, an import on top of work that asks first, Tab then Enter and the cell menu, dragging a column's width, Enter on a phone going into the next cell, a range dragged on a phone staying in its column and a tap during `=` saving nothing, tapped cells going into a formula that Done saves with its bracket closed, ⌖ on a phone picking from the grid, a file opened on a phone leaving one message at a time, twelve rows or more and a sideways swipe that works, with "saved in this browser" moved to the save status, a phone on its side folding the bars into two rows with six rows of grid and a long press opening the cell menu, keyboard only, undo, editing after undoing a new sheet, a cut across sheets, a formula bar that never writes a stale value, announcements, the AI assistant, the CSP, an API connected from the browser (nothing reaching `/api/*`), an API without CORS getting the IT checklist, no page calling itself a demo, every page in the sitemap naming its own canonical with the landing page carrying the Search Console tag, the phone tab bar, cross-sheet values on screen after a PDF export, a sort and undo (needs a build · `E2E_ONLY=` runs just the matching flows while editing) | | `npm run check:ai` | Asks the real Claude with your own key and checks the formulas against what this engine can evaluate — not in `verify`, because it needs a key and costs money | | `npm run verify` | Everything, before a push: lint → check:readme → check:screens → check:deps → test → check:mutants → build → check:bundle → check:a11y → check:e2e (~5 min) | | `npm run verify:quick` | The same gates minus `check:mutants`, `check:a11y`, `check:e2e` and `check:deps` — **37 seconds**, for the loop while writing. Not a substitute for `verify` before a push | @@ -945,6 +945,10 @@ Behind the scenes: one live cell changes nothing, as Excel refuses a paste over protected cells, and the note under the cursor's cell names the cells, e.g. "A2:B3 is live data, so nothing was changed". It is not applied around them, since that would shift rows against each other. Typing on a range whose first cell is live is answered the same way. +- **A sort whose range reaches a live block is refused whole** (#235). The sort used to move the block's rows and + say it was done; the next refresh wrote the block back in the source's order while the columns typed beside it + stayed sorted, so a note sat on another product's row without a word. Now nothing moves, and the same note names + the live cells. A range that does not reach the block (only the person's own columns selected) sorts as before. - Refreshes **never enter the undo history** (zundo is paused during the write) — one Ctrl+Z undoes the whole placed block, and "Change" (clear the old block + place the new one) counts as a single step too. - **Drag and drop still works** for people who prefer it, it's just no longer the primary path. @@ -3037,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 (2007 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 (2010 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 @@ -4089,20 +4093,20 @@ the framework bundle itself, which isn't a trade worth making here. Written down ## 🧪 Testing ```bash -npm test # 2007 cases across 144 files, via Vitest +npm test # 2010 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 2007 cases opens the app**, and nearly every bug this project found by hand lived in +**But not one of those 2010 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` instead of computed values (both tested) · one new button pushed the language toggle 42px off the screen. ```bash -npm run check:e2e # 59 flows in a real browser (needs a build) +npm run check:e2e # 60 flows in a real browser (needs a build) ``` Flows are picked by one rule: **would a unit test already catch it?** If yes it does not belong there. What @@ -4214,10 +4218,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.) -> **2007 tests passed, and 43% of the assistant's answers were unusable** — because those tests mock +> **2010 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 2007 passing tests could not catch](#-what-2007-passing-tests-could-not-catch), repeatable +> [What 2010 passing tests could not catch](#-what-2010-passing-tests-could-not-catch), repeatable > with `npm run check:ai`. | File | Cases | Tests | @@ -4280,6 +4284,7 @@ code. Proving a gate means checking that the thing you meant to break actually b | `store/sheetNaming.test.ts` | 6 | Sheet names through the real store (#54): QA's four cases (+ after a delete, a rename onto a name in use, a case twin, a forbidden character), a save from before the fix opening with valid names and formulas on the right tab, the export read as XML with cross-sheet `` values real (#101), and a failed export announced | | `store/sheetRules.test.ts` | 11 | Both features at the store: a value outside the rule is not saved and is announced, rules and names follow row edits, deleting a name leaves the formula reading `#NAME?` rather than rewritten, and undo brings the name back | | `store/liveBlockStructure.test.ts` | 8 | Live data and row/column edits (#46), through the real store and then a refresh: the block moves with them, a total under it is not written over, an edit inside it is refused with the reason, deleting its header unlinks it and keeps the values, and undo brings the linked block back | +| `store/liveSortRefused.test.ts` | 3 | A sort whose range reaches a live block is refused whole and said (#235): from a live column, with no row moving and every note still on its own product after a refresh; from the person's own column beside the block; a range that does not reach the block still sorting | | `store/liveWriteRefused.test.ts` | 7 | A write that reaches a live block is refused whole and said (#232): a paste from the app's clipboard and one from another program that starts outside the block and runs into it, fill down/Ctrl+Enter/delete/cut, another way of writing a cell (the AI assistant), said once while the same note stays up with a paste outside the block still going in, and a paste over a template's structure said with the note rather than an alert, and Replace all skipping live cells | | `store/livePicker.test.ts` | 5 | A single-value live cell never writes a blank (#126): the default is worked out again when the table arrives late (five rows → the whole table), a single value with no number cannot be inserted, the person's own pick is kept, and the store refuses a value block with no column on add and on change | | `store/liveEmptyResult.test.ts` | 2 | A source answering with an empty list (#65): the block keeps its last header, the rows under it are cleared, a SUM reads 0 rather than the envelope, and it fills again when rows come back | @@ -4534,7 +4539,7 @@ What's not done yet, and why — to show this is a known gap, not something forg prerendering, measured at +10–15 ms of TTFB · an API a user connects from the browser enters that one user's `connect-src`, through a cookie validated entry by entry. Still open: CSP cannot stop a top-level navigation. - [x] **Tests that actually open the app (E2E) in CI** — done: `npm run check:e2e` drives Chromium - through 59 flows as its own CI job — a formula recalculating on screen, an `.xlsx` round trip through + through 60 flows as its own CI job — a formula recalculating on screen, an `.xlsx` round trip through the real buttons, keyboard-only navigation, undo, and whether anything is announced. Three bugs this project previously found by hand are now inside the gate's reach, and each gate was proved by breaking it. **The AI assistant is now covered too**, with `/api/ai/formula` stubbed: the range the panel diff --git a/README.md b/README.md index eb5a201..f987d96 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), 2007 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง +(Thai/English), 2010 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง --- @@ -106,7 +106,7 @@ top/bottom ranks, colour scales and data bars — and round-trips through Excel' --- -### 🧪 สิ่งที่เทสต์ 2007 เคสจับไม่ได้ +### 🧪 สิ่งที่เทสต์ 2010 เคสจับไม่ได้ เทสต์ของผู้ช่วย 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 ที่มองเห็น เป็นตัวเลขผิดที่มองไม่เห็น** -**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 2007 เทสต์ผ่าน, `axe` ไม่มี violation ทั้งสองหน้า +**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 2010 เทสต์ผ่าน, `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 2007 เคสด้วย Vitest | +| `npm test` | รัน unit test 2010 เคสด้วย Vitest | | `npm run check:readme` | ตรวจว่า README ยังตรงกับโค้ด (ลิงก์/ภาพ/จำนวนเทสต์/โมดูลใหม่/สองภาษาตรงกัน) | | `npm run check:screens` | ตัวเลขที่พิมพ์อยู่บนภาพหน้าจอยังตรงกับซอร์สไหม | | `npm run check:rls` | ยิงจริงสองบัญชีบน Supabase ของคุณ ว่าฐานข้อมูลปฏิเสธตามที่ policy เขียนไว้ไหม (ต้องตั้ง env) | @@ -389,7 +389,7 @@ npm run dev | `npm run check:bundle` | งบขนาด bundle + ไลบรารีคลาวด์ต้องอยู่ chunk แยก (ต้อง build ก่อน) | | `npm run check:mutants` | ทุบเอนจินทีละจุดแล้วดูว่าเทสต์จับได้ไหม — 32/32 (ไม่ต้อง build) | | `npm run check:a11y` | รัน axe บนทั้งสองหน้าที่ 390px และ 1280px + เช็กการเลื่อนแนวนอน (ต้อง build ก่อน) · `A11Y_WIDTH=390` รันครึ่งเดียว ซึ่งเป็นวิธีที่ CI ใช้ | -| `npm run check:e2e` | ขับแอปจริงในเบราว์เซอร์ 59 flow: Ctrl+V ลงเซลล์ข้อมูลสดไม่ถูกเขียนและไม่บอกว่าวางแล้ว ป้ายใต้เซลล์บอกว่าเป็นข้อมูลสด และรีเฟรชแล้วค่าเดิมไม่เปลี่ยน ส่วนพิมพ์บนช่วงที่เซลล์แรกเป็นเซลล์สดก็บอก (#232), พิมพ์ `999` แล้ว Enter บนเซลล์ข้อมูลสดได้ป้ายใต้เซลล์บอกเหตุผล Enter ปิดป้ายโดยเคอร์เซอร์อยู่ที่เดิมและค่าไม่เปลี่ยน ส่วน Delete และดับเบิลคลิกก็บอกเหมือนกัน (#207), เปิดแผงถาม AI แล้วสิ่งที่พิมพ์ลงช่องคำถาม ไม่ลงเซลล์ที่ถูกบัง และ Esc ปิดแผงแล้ว focus กลับเซลล์เดิม ทั้งที่ 1280 และ 390 (#203), บนมือถือแตะเซลล์สูตรครั้งที่สองแล้วช่องแก้ไขเปิดพร้อมสูตรครบ สิ่งที่พิมพ์ลงสูตรนั้น และเซลล์สองแถวล่างไม่ถูกทับ (#201) แตะเซลล์อื่นโดยไม่พิมพ์แล้วเลือกแค่เซลล์นั้น ไม่ยืดเป็นช่วง (#210), บนมือถือพิมพ์หลัง Enter ลงเซลล์ที่มีค่าแล้วแทนที่ค่าเดิม ไม่ต่อท้าย (#202), บนมือถือหลัง Enter ในแถบสูตรแตะเซลล์แล้วเลือกเซลล์นั้น ไม่ทำให้สูตรของเซลล์ถัดไปพัง และสูตรที่แค่เปิดดูไม่ถูกชี้ใส่ (#201), พิมพ์ลงช่อง dropdown ได้ค่าที่พิมพ์ครบคำ ค่านอกรายการถูกปฏิเสธ และเลือกจากรายการยังบันทึกได้ ทั้งที่ 1280 และ 390 (#198), ไฟล์ที่มีรูปและกราฟบอกก่อนว่าจะหายตอนเปิด ไฟล์ธรรมดาไม่เห็นอะไร และชื่อช่วงที่นำเข้าไม่ได้อยู่ในรายงานเดียวกัน (#83), แหล่งข้อมูลสดที่ตอบช้ากว่าหน้าต่างเลือกข้อมูลไม่ทำให้เซลล์กลายเป็นช่องว่าง (#126), แตะครั้งเดียวบนมือถือแค่เลือกและบอกให้แตะอีกครั้ง สิ่งที่พิมพ์ไม่หายเงียบ (#171), Enter หรือ ↓ ที่แถวสุดท้ายเพิ่มแถวโดยไม่มีค่าไหนถูกทับและ undo ครั้งเดียวเอาค่ากับแถวออกแล้วพิมพ์ต่อได้ (#170), บนมือถือ undo ตอนช่องแก้ไขเปิดอยู่บนแถวที่ Enter งอกขึ้นมาแล้วช่องปิด บอกว่าทิ้งข้อความที่พิมพ์ค้าง และค่าถัดไปพิมพ์ลงได้ (#191), หน้าสูตรกดลองในตารางแล้วค่าตรงกับบนหน้า และบนงานที่มีอยู่ถามก่อน (#149), ตัวเลขที่วางจาก clipboard แบบที่ Excel ก๊อปออกมารวมได้ถูกและรหัสยังเป็นข้อความ (#52), ผู้ช่วย AI ใส่ยอดรวมใต้คอลัมน์และส่งคำถามที่มีเงื่อนไขไปฟอร์ม SUMIF (#62–#64), ปิดแท็บที่แก้อยู่แล้วแท็บที่ดูอยู่แก้ต่อได้เองพร้อมงานครบ (#146), แอปพังแล้วหน้ากู้งานขึ้น ไฟล์ที่โหลดมีงานครบ และลองอีกครั้งกลับมา (#145), วันที่ พ.ศ. นับได้จริงและคำสั่งแปลงเป็นวันที่จากเมนูคลิกขวา (#82), ชื่อชีตซ้ำหรือผิดกติกา Excel ถูกแก้ตอนเปิด ถูกปฏิเสธตอนพิมพ์ และส่งออกได้ไฟล์ที่ชื่อกับสูตรถูก (#54), แท็บที่สองถามก่อน ขอใช้แล้วแท็บเดิมดูอย่างเดียวและไม่มีงานหาย (#47), วันที่ในคอลัมน์กว้างค่าตั้งต้นของ Excel ขึ้นเป็นวันที่ไม่ใช่ `###` (1280 และ 390), ชีตชื่อไทยดาวน์โหลดเป็นไฟล์ชื่อไทย, เรียงชีตตัวอย่างสองภาษาแล้วทุกแถวยังคูณของตัวเอง และการเรียงที่สูตรจะผิดถามก่อน, เปอร์เซ็นต์ที่บันทึกไว้ก่อน #53 ยังขึ้นเหมือนเดิม, เปิดมาเป็นตารางว่างและข้อมูลตัวอย่างที่ค้างในเบราว์เซอร์ไม่กลับมา, ไฟล์ใหม่ถามก่อนและ undo ได้, พิมพ์สูตร, ส่งออก-นำเข้า `.xlsx`, เปิดไฟล์ทับงานแล้วถามก่อน, Tab แล้ว Enter กับเมนูเซลล์, ลากความกว้างคอลัมน์, Enter บนมือถือเข้าช่องถัดไป, ลากเลือกช่วงบนมือถือไม่เลยคอลัมน์และแตะระหว่างพิมพ์ `=` ไม่บันทึกทับ, แตะเซลล์ใส่ลงสูตรแล้วกดเสร็จได้สูตรที่ปิดวงเล็บ และ ⌖ บนมือถือเลือกช่วงจากตารางได้, หลังเปิดไฟล์บนมือถือข้อความขึ้นทีละอัน ตารางเหลือ 12 แถวขึ้นไปและปัดข้างได้ บันทึกในเบราว์เซอร์ย้ายไปอยู่ที่สถานะบนแถบบน, มือถือแนวนอนแถบรวมเหลือสองแถว เห็นตาราง 6 แถวขึ้นไป และกดค้างที่เซลล์เปิดเมนูเซลล์, คีย์บอร์ดล้วน, undo, แก้ต่อหลัง undo การเพิ่มชีต, ตัดข้ามชีต, แถบสูตรไม่เขียนค่าค้าง, เสียงประกาศ, ผู้ช่วย AI, CSP, ต่อ API จากเบราว์เซอร์ (ไม่มีอะไรถึง `/api/*`), API ที่ไม่ตอบ CORS ได้รายการตรวจสำหรับ IT, ไม่มีหน้าไหนเรียกตัวเองว่าเดโม, ทุกหน้าใน sitemap ประกาศ canonical ของตัวเองและหน้าแรกมีแท็กยืนยัน Search Console, แถบล่างบนมือถือ, ค่าข้ามชีตบนจอหลังส่งออก PDF/sort/undo (ต้อง build ก่อน · `E2E_ONLY=<ส่วนของชื่อ>` รันเฉพาะ flow ที่ชื่อตรง ระหว่างแก้) | +| `npm run check:e2e` | ขับแอปจริงในเบราว์เซอร์ 60 flow: เรียงข้อมูลข้างบล็อกข้อมูลสดถูกปฏิเสธพร้อมป้าย ไม่มีแถวขยับ และหลังรีเฟรชโน้ตทุกแถวยังอยู่กับสินค้าของตัวเอง (#235), Ctrl+V ลงเซลล์ข้อมูลสดไม่ถูกเขียนและไม่บอกว่าวางแล้ว ป้ายใต้เซลล์บอกว่าเป็นข้อมูลสด และรีเฟรชแล้วค่าเดิมไม่เปลี่ยน ส่วนพิมพ์บนช่วงที่เซลล์แรกเป็นเซลล์สดก็บอก (#232), พิมพ์ `999` แล้ว Enter บนเซลล์ข้อมูลสดได้ป้ายใต้เซลล์บอกเหตุผล Enter ปิดป้ายโดยเคอร์เซอร์อยู่ที่เดิมและค่าไม่เปลี่ยน ส่วน Delete และดับเบิลคลิกก็บอกเหมือนกัน (#207), เปิดแผงถาม AI แล้วสิ่งที่พิมพ์ลงช่องคำถาม ไม่ลงเซลล์ที่ถูกบัง และ Esc ปิดแผงแล้ว focus กลับเซลล์เดิม ทั้งที่ 1280 และ 390 (#203), บนมือถือแตะเซลล์สูตรครั้งที่สองแล้วช่องแก้ไขเปิดพร้อมสูตรครบ สิ่งที่พิมพ์ลงสูตรนั้น และเซลล์สองแถวล่างไม่ถูกทับ (#201) แตะเซลล์อื่นโดยไม่พิมพ์แล้วเลือกแค่เซลล์นั้น ไม่ยืดเป็นช่วง (#210), บนมือถือพิมพ์หลัง Enter ลงเซลล์ที่มีค่าแล้วแทนที่ค่าเดิม ไม่ต่อท้าย (#202), บนมือถือหลัง Enter ในแถบสูตรแตะเซลล์แล้วเลือกเซลล์นั้น ไม่ทำให้สูตรของเซลล์ถัดไปพัง และสูตรที่แค่เปิดดูไม่ถูกชี้ใส่ (#201), พิมพ์ลงช่อง dropdown ได้ค่าที่พิมพ์ครบคำ ค่านอกรายการถูกปฏิเสธ และเลือกจากรายการยังบันทึกได้ ทั้งที่ 1280 และ 390 (#198), ไฟล์ที่มีรูปและกราฟบอกก่อนว่าจะหายตอนเปิด ไฟล์ธรรมดาไม่เห็นอะไร และชื่อช่วงที่นำเข้าไม่ได้อยู่ในรายงานเดียวกัน (#83), แหล่งข้อมูลสดที่ตอบช้ากว่าหน้าต่างเลือกข้อมูลไม่ทำให้เซลล์กลายเป็นช่องว่าง (#126), แตะครั้งเดียวบนมือถือแค่เลือกและบอกให้แตะอีกครั้ง สิ่งที่พิมพ์ไม่หายเงียบ (#171), Enter หรือ ↓ ที่แถวสุดท้ายเพิ่มแถวโดยไม่มีค่าไหนถูกทับและ undo ครั้งเดียวเอาค่ากับแถวออกแล้วพิมพ์ต่อได้ (#170), บนมือถือ undo ตอนช่องแก้ไขเปิดอยู่บนแถวที่ Enter งอกขึ้นมาแล้วช่องปิด บอกว่าทิ้งข้อความที่พิมพ์ค้าง และค่าถัดไปพิมพ์ลงได้ (#191), หน้าสูตรกดลองในตารางแล้วค่าตรงกับบนหน้า และบนงานที่มีอยู่ถามก่อน (#149), ตัวเลขที่วางจาก clipboard แบบที่ Excel ก๊อปออกมารวมได้ถูกและรหัสยังเป็นข้อความ (#52), ผู้ช่วย AI ใส่ยอดรวมใต้คอลัมน์และส่งคำถามที่มีเงื่อนไขไปฟอร์ม SUMIF (#62–#64), ปิดแท็บที่แก้อยู่แล้วแท็บที่ดูอยู่แก้ต่อได้เองพร้อมงานครบ (#146), แอปพังแล้วหน้ากู้งานขึ้น ไฟล์ที่โหลดมีงานครบ และลองอีกครั้งกลับมา (#145), วันที่ พ.ศ. นับได้จริงและคำสั่งแปลงเป็นวันที่จากเมนูคลิกขวา (#82), ชื่อชีตซ้ำหรือผิดกติกา Excel ถูกแก้ตอนเปิด ถูกปฏิเสธตอนพิมพ์ และส่งออกได้ไฟล์ที่ชื่อกับสูตรถูก (#54), แท็บที่สองถามก่อน ขอใช้แล้วแท็บเดิมดูอย่างเดียวและไม่มีงานหาย (#47), วันที่ในคอลัมน์กว้างค่าตั้งต้นของ Excel ขึ้นเป็นวันที่ไม่ใช่ `###` (1280 และ 390), ชีตชื่อไทยดาวน์โหลดเป็นไฟล์ชื่อไทย, เรียงชีตตัวอย่างสองภาษาแล้วทุกแถวยังคูณของตัวเอง และการเรียงที่สูตรจะผิดถามก่อน, เปอร์เซ็นต์ที่บันทึกไว้ก่อน #53 ยังขึ้นเหมือนเดิม, เปิดมาเป็นตารางว่างและข้อมูลตัวอย่างที่ค้างในเบราว์เซอร์ไม่กลับมา, ไฟล์ใหม่ถามก่อนและ undo ได้, พิมพ์สูตร, ส่งออก-นำเข้า `.xlsx`, เปิดไฟล์ทับงานแล้วถามก่อน, Tab แล้ว Enter กับเมนูเซลล์, ลากความกว้างคอลัมน์, Enter บนมือถือเข้าช่องถัดไป, ลากเลือกช่วงบนมือถือไม่เลยคอลัมน์และแตะระหว่างพิมพ์ `=` ไม่บันทึกทับ, แตะเซลล์ใส่ลงสูตรแล้วกดเสร็จได้สูตรที่ปิดวงเล็บ และ ⌖ บนมือถือเลือกช่วงจากตารางได้, หลังเปิดไฟล์บนมือถือข้อความขึ้นทีละอัน ตารางเหลือ 12 แถวขึ้นไปและปัดข้างได้ บันทึกในเบราว์เซอร์ย้ายไปอยู่ที่สถานะบนแถบบน, มือถือแนวนอนแถบรวมเหลือสองแถว เห็นตาราง 6 แถวขึ้นไป และกดค้างที่เซลล์เปิดเมนูเซลล์, คีย์บอร์ดล้วน, undo, แก้ต่อหลัง undo การเพิ่มชีต, ตัดข้ามชีต, แถบสูตรไม่เขียนค่าค้าง, เสียงประกาศ, ผู้ช่วย AI, CSP, ต่อ API จากเบราว์เซอร์ (ไม่มีอะไรถึง `/api/*`), API ที่ไม่ตอบ CORS ได้รายการตรวจสำหรับ IT, ไม่มีหน้าไหนเรียกตัวเองว่าเดโม, ทุกหน้าใน sitemap ประกาศ canonical ของตัวเองและหน้าแรกมีแท็กยืนยัน Search Console, แถบล่างบนมือถือ, ค่าข้ามชีตบนจอหลังส่งออก PDF/sort/undo (ต้อง build ก่อน · `E2E_ONLY=<ส่วนของชื่อ>` รันเฉพาะ flow ที่ชื่อตรง ระหว่างแก้) | | `npm run check:ai` | ถาม Claude จริงด้วย key ของคุณ แล้วเช็กว่าสูตรที่ได้เอนจินนี้รันได้จริงไหม — ไม่อยู่ใน `verify` เพราะต้องใช้ key และมีค่าใช้จ่าย | | `npm run verify` | รันรวดเดียวก่อน push: lint → check:readme → check:screens → check:deps → test → check:mutants → build → check:bundle → check:a11y → check:e2e (~5 นาที) | | `npm run verify:quick` | ด่านเดียวกันแบบตัด `check:mutants`, `check:a11y`, `check:e2e`, `check:deps` ออก — **37 วินาที** สำหรับลูประหว่างเขียน ไม่ใช่ตัวแทนของ `verify` ตอน push | @@ -857,6 +857,9 @@ e2e มี flow ที่ชี้ไปหา API ที่ไม่ตอบ C แต่รีเฟรชรอบถัดไปทับหายเงียบ และ Ctrl+Z ก็เอาคืนไม่ได้ · ตอนนี้ถ้าช่วงที่จะวาง เติม (ลาก/Ctrl+D/Ctrl+R/Ctrl+Enter) ตัด หรือลบ ไปถึงเซลล์สดแม้แต่ช่องเดียว ก็ไม่เปลี่ยนอะไรเลยแบบที่ Excel ปฏิเสธการวางทับเซลล์ที่ป้องกันไว้ แล้วป้ายใต้เซลล์ที่เคอร์เซอร์อยู่บอกว่าเซลล์ไหน เช่น "A2:B3 เป็นข้อมูลสด จึงไม่ได้เปลี่ยนอะไร" ไม่วางเฉพาะช่องว่างเพราะแถวจะเลื่อนไม่ตรงกัน · พิมพ์บนช่วงที่เซลล์แรกเป็นเซลล์สดก็ขึ้นป้ายเหมือนกัน +- **เรียงข้อมูลที่ช่วงไปถึงบล็อกข้อมูลสดก็ถูกปฏิเสธทั้งก้อน** (#235) — เดิมการเรียงย้ายแถวของบล็อกไปด้วยแล้วบอกว่าเรียงแล้ว + พอรีเฟรชรอบถัดไป บล็อกถูกเขียนกลับตามลำดับของแหล่งข้อมูล แต่คอลัมน์ที่พิมพ์เองข้างๆ ยังเรียงอยู่ โน้ตจึงไปอยู่ผิดสินค้าเงียบๆ + ตอนนี้ไม่มีอะไรขยับ และป้ายเดียวกันบอกว่าเซลล์ไหนเป็นข้อมูลสด · ช่วงที่ไม่ถึงบล็อก (เช่นเลือกเฉพาะคอลัมน์ของตัวเอง) ยังเรียงได้ตามเดิม - การรีเฟรช**ไม่ปนเข้าประวัติ undo** (หยุด zundo ชั่วคราวระหว่างเขียน) กด Ctrl+Z ครั้งเดียวจะย้อนการวางทั้งบล็อก และปุ่ม "เปลี่ยน" (ล้างของเดิม + วางของใหม่) ก็นับเป็น 1 ขั้นตอนเดียวเช่นกัน - ยัง**ลากวางได้เหมือนเดิม**สำหรับคนที่ถนัดลาก แต่ไม่ใช่ทางหลักอีกต่อไป @@ -2641,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, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (2007 เคส) | +| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (2010 เคส) | > **หมายเหตุ:** ไม่ได้ใช้ไลบรารีคำนวณสูตรสำเร็จรูป (เช่น HyperFormula) แต่เขียน **เอนจินคำนวณสูตรขึ้นเอง** > ทั้ง tokenizer, parser, evaluator และฟังก์ชันต่างๆ เพื่อควบคุมพฤติกรรมได้เต็มที่ ดูรายละเอียดที่หัวข้อ @@ -3548,19 +3551,19 @@ B อ่านและแก้ได้ แต่ยังยึดเป็ ## 🧪 การทดสอบ ```bash -npm test # 2007 เคส ใน 144 ไฟล์ ด้วย Vitest +npm test # 2010 เคส ใน 145 ไฟล์ ด้วย Vitest ``` โฟกัสเทสต์ไปที่ **เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, การแปลง JSON เป็นตาราง, การไล่ดึงหน้าถัดไป, การถอยเมื่อโดน rate limit, แม่แบบจากไฟล์ Excel, กฎจัดรูปแบบตามเงื่อนไข และการวางบล็อกข้อมูลสด** — ส่วนที่เป็น pure function ล้วน ไม่ต้องพึ่ง React/DOM จึงเทสต์ได้เร็วและมั่นใจได้สูง -**แต่ 2007 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์ +**แต่ 2010 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์ ทุกตัวแทบทั้งหมด — ปุ่ม "+ แถว" บนแถบเครื่องมือเรียก `addRow` ที่ไม่ประกาศอะไรเลย ขณะที่ `insertRowAtSelection` ข้าง ๆ ประกาศถูกต้อง (ทั้งคู่เทสต์ผ่าน) · ผู้ช่วย AI ส่งช่วงที่รวมหัวคอลัมน์ไปด้วย เพราะตัวประกอบ context อ่าน `sheet.cells` ที่เป็นสูตรดิบแทนค่าที่คำนวณแล้ว (ทั้งคู่เทสต์ผ่าน) · ปุ่มใหม่หนึ่งปุ่มดันปุ่มสลับภาษาตกขอบจอไป 42px ```bash -npm run check:e2e # 59 flow ในเบราว์เซอร์จริง (ต้อง build ก่อน) +npm run check:e2e # 60 flow ในเบราว์เซอร์จริง (ต้อง build ก่อน) ``` เกณฑ์เลือก flow มีข้อเดียว: **unit test จับได้อยู่แล้วหรือเปล่า** ถ้าจับได้ ไม่ต้องอยู่ที่นี่ ที่เหลือคือรอยต่อ — @@ -3654,9 +3657,9 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด ในตารางแล้วสองข้อในflow ที่สามตกทันที (ครั้งแรกที่ลองปิดยังเขียวอยู่ เพราะ `case` ที่แทรกเข้าไปไปอยู่หลัง `case "ArrowRight"` เดิม กลายเป็นโค้ดตาย — การพิสูจน์ต้องเช็กด้วยว่าของที่ตั้งใจทำให้พังนั้นพังจริง) -> **2007 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้ +> **2010 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้ > มันจึงตอบตามที่คนเขียนเทสต์คิดว่ามันจะตอบ จำนวนเทสต์บอกได้แค่ว่าเราถามอะไรไปบ้าง ไม่ได้บอกว่าเราถาม -> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 2007 เคสจับไม่ได้](#-สิ่งที่เทสต์-2007-เคสจับไม่ได้) +> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 2010 เคสจับไม่ได้](#-สิ่งที่เทสต์-2010-เคสจับไม่ได้) > และรันซ้ำเองได้ด้วย `npm run check:ai` | ไฟล์ | เคส | ทดสอบอะไร | @@ -3715,6 +3718,7 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด | `store/sheetNaming.test.ts` | 6 | ชื่อชีตผ่าน store จริง (#54): สี่เคสของ QA (+ หลังลบแท็บ, เปลี่ยนชื่อทับ, ต่างแค่ตัวพิมพ์, ตัวอักษรต้องห้าม), งานที่บันทึกไว้ก่อนแก้เปิดมาแล้วชื่อถูกและสูตรชี้แท็บที่ถูก, ส่งออกแล้วอ่าน XML: `` ของสูตรข้ามชีตเป็นค่าจริง (#101) และส่งออกล้มแล้วแอปบอก | | `store/sheetRules.test.ts` | 11 | สองอย่างนี้ที่ระดับ store: ค่าที่ผิดกติกาไม่ถูกบันทึกและถูกพูดออกมา, กติกาและชื่อเลื่อนตามแถว, ลบชื่อแล้วสูตรขึ้น `#NAME?` แทนที่จะถูกเขียนใหม่ และ undo คืนชื่อกลับมา | | `store/liveBlockStructure.test.ts` | 8 | ข้อมูลสดกับการแทรก/ลบแถวและคอลัมน์ (#46) ผ่าน store จริงแล้วตามด้วยรอบรีเฟรช: บล็อกเลื่อนตาม แถวยอดรวมใต้บล็อกไม่ถูกเขียนทับ, แทรก/ลบกลางบล็อกถูกปฏิเสธพร้อมเหตุผล, ลบแถวหัว = เลิกเชื่อมแต่ค่ายังอยู่ และ undo คืนบล็อกที่ผูกไว้ | +| `store/liveSortRefused.test.ts` | 3 | การเรียงที่ช่วงไปถึงบล็อกข้อมูลสดถูกปฏิเสธทั้งก้อนและบอก (#235): เรียงจากคอลัมน์สด ไม่มีแถวขยับและหลังรีเฟรชโน้ตยังอยู่กับสินค้าของตัวเอง, เรียงจากคอลัมน์ของตัวเองข้างบล็อก, ช่วงที่ไม่ถึงบล็อกยังเรียงได้ | | `store/liveWriteRefused.test.ts` | 7 | การเขียนที่ไปถึงบล็อกข้อมูลสดถูกปฏิเสธทั้งก้อนและบอก (#232): วางจาก clipboard ของแอปและจากโปรแกรมอื่นที่เริ่มนอกบล็อกแล้วล้นเข้ามา, เติมลง/Ctrl+Enter/ลบ/ตัด, การเขียนเซลล์ทางอื่น (เช่นผู้ช่วย AI), บอกครั้งเดียวขณะป้ายเดิมยังอยู่ และการวางนอกบล็อกยังลงปกติ, วางทับโครงแม่แบบบอกด้วยป้ายแทน alert, แทนที่ทั้งหมดข้ามเซลล์สด | | `store/livePicker.test.ts` | 5 | ข้อมูลสดแบบค่าเดียวไม่เขียนทับเซลล์เป็นช่องว่าง (#126): ตัวเลือกตั้งต้นคิดใหม่เมื่อตารางมาถึงทีหลัง (ห้าแถว → ตารางทั้งหมด), ค่าเดียวที่ยังไม่มีตัวเลขกดใส่ไม่ได้, คงสิ่งที่ผู้ใช้เลือก และ store ไม่รับบล็อกค่าเดียวที่ไม่มีคอลัมน์ทั้งตอนเพิ่มและตอนเปลี่ยน | | `store/liveEmptyResult.test.ts` | 2 | แหล่งที่ตอบรายการว่าง (#65): บล็อกเหลือแถวหัวชุดล่าสุด แถวข้างใต้ถูกล้าง สูตรรวมได้ 0 ไม่ใช่ค่าของซอง และเติมกลับเมื่อมีแถวอีกครั้ง | @@ -3933,7 +3937,7 @@ CI: `npm run verify` รวมทุกอย่างไว้แล้ว — inline script ไม่ใช่ไฟล์จึงแฮชไม่ได้ หน้าเลย hydrate ไม่ขึ้นเลย ราคาที่จ่ายคือ prerender (TTFB +10–15 ms วัดแล้ว) · API ที่ผู้ใช้ต่อจากเบราว์เซอร์เข้า `connect-src` ของผู้ใช้คนนั้นคนเดียว ผ่านคุกกี้ที่ตรวจทุกตัว ยังเหลือ: CSP หยุด top-level navigation ไม่ได้ -- [x] **เทสต์ที่เปิดแอปจริง (E2E) ใน CI** — ทำแล้ว: `npm run check:e2e` ขับ Chromium ผ่าน 59 flow +- [x] **เทสต์ที่เปิดแอปจริง (E2E) ใน CI** — ทำแล้ว: `npm run check:e2e` ขับ Chromium ผ่าน 60 flow เป็น job ของตัวเองใน CI — พิมพ์สูตรแล้วค่าขยับ, ส่งออก `.xlsx` แล้วนำกลับเข้ามา, คีย์บอร์ดล้วน, undo, และเสียงประกาศ บั๊กสามตัวที่เคยเจอด้วยการนั่งกดเอง อยู่ในขอบเขตของด่านนี้แล้ว และพิสูจน์ด้วยการทำให้พังจริง **และผู้ช่วย AI อยู่ในนั้นแล้ว** ด้วยการ stub `/api/ai/formula` ไว้ — ตรวจทั้งช่วงเซลล์ที่แพเนลส่งออกไป diff --git a/public/screenshots/27-landing-stats.png b/public/screenshots/27-landing-stats.png index 79e0614..706e20e 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 3d3d454..6664c6d 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 4d94c95..8d01c30 100644 --- a/public/screenshots/screenshots.json +++ b/public/screenshots/screenshots.json @@ -5,7 +5,7 @@ "27-landing-stats.png": { "paletteFormulas": 37, "engineFunctions": 66, - "tests": 2007, + "tests": 2010, "securityTests": 325 }, "38-landing-problems.png": { @@ -21,7 +21,7 @@ "en/27-landing-stats.png": { "paletteFormulas": 37, "engineFunctions": 66, - "tests": 2007, + "tests": 2010, "securityTests": 325 }, "en/38-landing-problems.png": { diff --git a/public/social-preview.png b/public/social-preview.png index a649462..59ed9e4 100644 Binary files a/public/social-preview.png and b/public/social-preview.png differ diff --git a/scripts/check-e2e.mjs b/scripts/check-e2e.mjs index e81e1ab..0341946 100644 --- a/scripts/check-e2e.mjs +++ b/scripts/check-e2e.mjs @@ -1600,6 +1600,46 @@ const FLOWS = [ note(/^B5 (เป็นข้อมูลสด|is live data)/.test((await cell(page, 4, 1).getAttribute("data-refused")) ?? ""), "typing on B5:C6 says why under B5"); }, }, + { + // #235. A sort over a live block moved its rows and was said as done; the next refresh wrote the + // block back in the source's order while the notes typed beside it stayed sorted, so a note sat + // on another product's row without a word. The seam is a sort meeting a block that rewrites itself. + name: "sorting next to a live block is refused whole and said, and every note stays on its product (#235)", + async run(page) { + await cell(page, 0, 0).click(); + await page.getByRole("button", { name: label.liveData }).first().click(); + await page.getByRole("button", { name: label.trySales }).click(); + const form = page.getByRole("dialog"); + await form.getByRole("button", { name: label.test }).click(); + await form.getByText(/ได้ข้อมูล 5 แถว|Got 5 rows/).waitFor({ timeout: 15_000 }); + await form.getByRole("button", { name: label.saveAndAdd }).click(); + const insert = page.getByRole("dialog").getByRole("button", { name: label.insert }); + await insert.waitFor({ timeout: 15_000 }); + await insert.click(); + await page.waitForFunction(() => document.querySelector('td[data-row="1"][data-col="0"]')?.innerText.trim() === "CF-01", null, { timeout: 15_000 }); + + // A note per product in H, beside the block, as a person keeps them. + const pairs = async () => { + const out = []; + for (let r = 1; r <= 5; r++) out.push([(await cell(page, r, 0).innerText()).trim(), (await cell(page, r, 7).innerText()).trim()]); + return out; + }; + for (let r = 1; r <= 5; r++) await typeInCell(page, r, 7, `n-${(await cell(page, r, 0).innerText()).trim()}`); + const before = await pairs(); + + await cell(page, 1, 3).click(); + await page.getByRole("button", { name: label.tools }).click(); + await page.getByTitle(label.sortAsc).click(); + note(/^A2:G6 (เป็นข้อมูลสด|is live data)/.test((await cell(page, 1, 3).getAttribute("data-refused")) ?? ""), "D2 says under itself why the sort did not happen"); + const spoken = await page.evaluate(() => [...document.querySelectorAll('[role="status"][aria-live]')].map((e) => e.textContent).join(" ")); + note(!/เรียง|[Ss]orted/.test(spoken), `nothing is said as sorted ("${spoken.trim().slice(0, 80)}")`); + note(JSON.stringify(await pairs()) === JSON.stringify(before), "no row moved"); + await page.waitForTimeout(6000); // past the source's 5-second refresh, when the notes used to land on other rows + const after = await pairs(); + const wrong = after.filter(([sku, n]) => n !== `n-${sku}`); + note(wrong.length === 0, `after a refresh every note is on its own product (${wrong.length} on another)`); + }, + }, { // #83. The app is used to open a file, fix it and send it back, and a file with pictures or // Excel's own charts loses them on the way — which the person used to hear from whoever got diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx index 73a3c6f..9d3c81c 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"], - ["2007", "automated tests"], + ["2010", "automated tests"], ["325", "of them security"], ["0", "formula libraries"], ]; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index d7b3943..39d7300 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: "2007", label: "automated tests" }, + { value: "2010", 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 0ff337f..5c8f918 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: "2007", label: "เทสต์อัตโนมัติ" }, + { value: "2010", label: "เทสต์อัตโนมัติ" }, { value: "325", label: "เทสต์ด้านความปลอดภัย" }, { value: "0", label: "ไลบรารีคำนวณสูตร" }, ], diff --git a/src/store/liveSortRefused.test.ts b/src/store/liveSortRefused.test.ts new file mode 100644 index 0000000..be8333c --- /dev/null +++ b/src/store/liveSortRefused.test.ts @@ -0,0 +1,85 @@ +// Copyright 2026 Suruch Chakrapeesirisuk +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it } from "vitest"; +import type { TableData } from "@/lib/dataSources/types"; +import { useSheetStore } from "./sheetStore"; + +/** + * A sort over a live block (#235). + * + * The sort moved whole rows, live cells and all, and was said as done. The next refresh wrote the + * block back in the source's order while the notes typed beside it stayed sorted, so a note sat on + * another product's row without a word. A sort whose range reaches a live cell is refused whole. + */ +const state = () => useSheetStore.getState(); +const cells = () => state().sheets[0].sheet.cells; +const select = (startRow: number, startCol: number, endRow = startRow, endCol = startCol) => + state().setSelection({ startRow, startCol, endRow, endCol, anchorRow: startRow, anchorCol: startCol }); +const column = (c: number, from = 1, to = 3) => cells().slice(from, to + 1).map((r) => r[c]); + +const TABLE: TableData = { + columns: [ + { key: "sku", label: "sku", numeric: false }, + { key: "price", label: "price", numeric: true }, + ], + rows: [ + ["CF-01", 50], + ["BK-01", 30], + ["BK-02", 40], + ], + fetchedAt: new Date(0).toISOString(), +}; +const refresh = () => state().applyLiveData("sales", TABLE); + +beforeEach(() => { + state().startBlank(); + state().dismissRefusal(); + useSheetStore.temporal.getState().clear(); + // The block is A1:B4 (header + three rows); C2:C4 are the person's notes, one per product. + select(0, 0); + state().addLiveBlock({ sourceId: "sales", anchorRow: 0, anchorCol: 0, kind: "table" }, TABLE); + state().setCellRaw(1, 2, "n-CF-01"); + state().setCellRaw(2, 2, "n-BK-01"); + state().setCellRaw(3, 2, "n-BK-02"); +}); + +describe("a sort that reaches a live block is refused whole and said (#235)", () => { + it("from a live column: nothing moves, the note names the cells, and a refresh leaves every note on its product", () => { + select(1, 1); + state().sortSelection(false); + expect(column(0)).toEqual(["CF-01", "BK-01", "BK-02"]); + expect(column(2)).toEqual(["n-CF-01", "n-BK-01", "n-BK-02"]); + expect(state().announcement?.text).toMatch(/^A2:B4 (เป็นข้อมูลสด|is live data)/); + expect(state().refusal).toMatchObject({ row: 1, col: 1 }); + refresh(); + expect(column(0)).toEqual(["CF-01", "BK-01", "BK-02"]); + expect(column(2)).toEqual(["n-CF-01", "n-BK-01", "n-BK-02"]); + }); + + it("from the person's own column beside it, whose rows the sort would take the block with", () => { + select(2, 2); + state().sortSelection(true); + expect(column(2)).toEqual(["n-CF-01", "n-BK-01", "n-BK-02"]); + expect(state().refusal).toMatchObject({ row: 2, col: 2 }); + }); + + it("a range that does not reach the block sorts as before", () => { + state().setCellRaw(6, 0, "3"); + state().setCellRaw(7, 0, "1"); + state().setCellRaw(8, 0, "2"); + // Beside the block's rows too, as long as the selected range stays out of its columns. + select(1, 3, 3, 3); + state().setCellRaw(1, 3, "9"); + state().setCellRaw(2, 3, "7"); + state().setCellRaw(3, 3, "8"); + select(1, 3, 3, 3); + state().sortSelection(true); + expect(column(3)).toEqual(["7", "8", "9"]); + expect(column(0)).toEqual(["CF-01", "BK-01", "BK-02"]); + select(7, 0); + state().sortSelection(true); + expect(column(0, 6, 8)).toEqual(["1", "2", "3"]); + expect(state().refusal).toBeNull(); + }); +}); diff --git a/src/store/sheetStore.ts b/src/store/sheetStore.ts index e7637ef..d03d8ba 100644 --- a/src/store/sheetStore.ts +++ b/src/store/sheetStore.ts @@ -1670,6 +1670,13 @@ export const useSheetStore = create()( const selection = activeSelectionOf(s); const computed = computeTab(sheet, s.sheets); const range = detectSortRange(sheet, computed, selection, selection.anchorRow); + // A sort that moves live rows is undone by the next refresh, which writes the block back + // in the source's order while the person's own columns beside it stay sorted (#235). + const refused = refusedWrite(s, range.startRow, range.startCol, range.endRow, range.endCol, { + row: selection.anchorRow, + col: selection.anchorCol, + }); + if (refused) return { ...refused, sortWarning: null }; // Asked first, not refused: sometimes a running total is meant to be re-run on new // rows. But a sort that silently gives each row another row's numbers is how #48 cost // a tester 2,710 baht, so it is never silent.