test(sheets): pin three guarantees that mutation testing showed nothing held

Third review pass, run as mutation testing rather than reading: break a
behaviour, see whether anything fails. Most of the suite has teeth (removing
the coalesce reordering guard, the descending sort in +dim-delete --ranges, the
sub-op vocabulary check, the cells-vs-range dimension check, the border weight
normalization, the retired-enum tolerance and the batch freeze-collision
warning all get caught). Three did not.

- coalesceStyleStamps' adjacency rule. Widening the touch test from +1 to +2
  fuses ranges separated by one row, painting cells the caller never named,
  and passed every existing case — they are all contiguous, so they only
  constrain the rule from one side. Added the negative cases plus a coverage
  property: the set of cells the expanded stamps touch must equal the set the
  spec named. Coalescing rewrites a declarative spec, so this is the invariant
  that matters, not any single fusion.

- The --output-path receipt. Hard-coding complete:true passed the whole suite,
  yet the receipt is the only completeness signal on that path (the data went
  to a file) and the skill docs tell agents to read it before using the file.
  Now driven end to end for clean / per-range-truncated / has_more reads, with
  the file contents and bytes_written checked against the receipt.

- +table-get's whole-workbook char budget. Removing the per-sheet clamp lets
  each sheet spend --max-chars in full — a 30-sheet workbook pulls 30x what
  was allowed, with every individual request looking compliant. The outer
  loop's exhaustion check is a different mechanism and kept working, which is
  why nothing failed. Now asserted on the wire: sheet 2 must ask for less than
  sheet 1.
This commit is contained in:
xiongyuanwen-byted
2026-07-31 23:53:36 +08:00
parent 99ac5d6906
commit 01ae4cc521
3 changed files with 248 additions and 0 deletions

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"testing"
@@ -1687,3 +1688,66 @@ func TestValidColumnType_AcceptsEmpty(t *testing.T) {
t.Error(`validColumnType("float") = true, want false`)
}
}
// TestTableGet_CharBudgetSpansTheWholeWorkbook pins that --max-chars bounds the
// WHOLE multi-sheet read, not each sheet independently.
//
// The cap is a memory guard on a non-streaming path, so letting every sheet
// spend it in full would let a 30-sheet workbook pull 30x what the caller
// allowed — quietly, since each individual request looks compliant. The clamp
// that prevents it (charBudget in readSheetAsSpec) was unpinned: removing it
// passed the entire suite, because the outer loop's exhaustion check is a
// separate mechanism and keeps working.
//
// Asserted on the wire: sheet 2's request must ask for less than sheet 1's.
func TestTableGet_CharBudgetSpansTheWholeWorkbook(t *testing.T) {
t.Parallel()
const budget = 40000
structure := toolOutputStub(testToken, "read", `{"sheets":[`+
`{"sheet_id":"sh1","sheet_name":"S1","row_count":50,"column_count":3,"index":0},`+
`{"sheet_id":"sh2","sheet_name":"S2","row_count":50,"column_count":3,"index":1}`+
`]}`)
// One reusable stub answers both the current-region probes and the cell
// reads; every captured body is inspected below.
payload := `{"current_region":"A1:B2","ranges":[{"cells":[` +
`[{"value":"col1"},{"value":"col2"}],` +
`[{"value":"a"},{"value":"b"}]` +
`]}]}`
reads := toolOutputStub(testToken, "read", payload)
reads.Reusable = true
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--max-chars", strconv.Itoa(budget)}, structure, reads)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
var caps []int
for _, body := range reads.CapturedBodies {
var wire struct {
ToolName string `json:"tool_name"`
Input string `json:"input"`
}
if json.Unmarshal(body, &wire) != nil || wire.ToolName != "get_cell_ranges" {
continue
}
var input struct {
MaxChars int `json:"max_chars"`
}
if json.Unmarshal([]byte(wire.Input), &input) != nil || input.MaxChars == 0 {
continue
}
caps = append(caps, input.MaxChars)
}
if len(caps) < 2 {
t.Fatalf("want a cell read per sheet, captured caps = %v", caps)
}
if caps[0] > budget {
t.Errorf("first sheet asked for max_chars=%d, over the %d budget", caps[0], budget)
}
if caps[1] >= caps[0] {
t.Errorf("second sheet asked for max_chars=%d, not reduced by what the first consumed (%d) — the budget is per-workbook, not per-sheet", caps[1], caps[0])
}
}

View File

@@ -4,7 +4,10 @@
package sheets
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/extension/fileio"
@@ -115,3 +118,88 @@ func cellsGetToolInput(t *testing.T, extra []string) map[string]interface{} {
args := append([]string{"--url", testURL, "--sheet-name", "S1", "--range", "A1:B2"}, extra...)
return decodeToolInput(t, parseDryRunBody(t, CellsGet, args), "get_cell_ranges")
}
// TestEmitReadResult_ReceiptStatesCompleteness drives a real --output-path read
// end to end and checks the stdout receipt against the payload written to disk.
//
// The receipt is the ONLY completeness signal a caller gets on this path — the
// data went to a file, stdout carries just the summary — and the skill docs
// instruct agents to read `complete` before using the file. Nothing was pinning
// it: hard-coding complete:true passed the whole suite, which is exactly the
// failure that makes an agent analyze half a sheet believing it has all of it.
//
// Not parallel: t.Chdir scopes the relative --output-path to a temp dir.
func TestEmitReadResult_ReceiptStatesCompleteness(t *testing.T) {
cases := []struct {
name string
output string
wantComplete bool
}{
{
name: "clean read reports complete",
output: `{"ranges":[{"range":"A1:B2","values":[["x","y"]]}]}`,
wantComplete: true,
},
{
name: "per-range truncation flag reports incomplete",
output: `{"ranges":[{"range":"A1:B2","truncated":true,"values":[["x","y"]]}]}`,
wantComplete: false,
},
{
name: "top-level has_more reports incomplete",
output: `{"has_more":true,"ranges":[{"range":"A1:B2","values":[["x","y"]]}]}`,
wantComplete: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"output": tc.output},
},
}
stdout, err := runShortcutWithStubs(t, CellsGet, []string{
"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2",
"--output-path", "out.json", "--as", "user",
}, stub)
if err != nil {
t.Fatalf("read failed: %v", err)
}
receipt := decodeEnvelopeData(t, stdout)
if got := receipt["complete"]; got != tc.wantComplete {
t.Errorf("complete = %v, want %v (receipt=%v)", got, tc.wantComplete, receipt)
}
if !tc.wantComplete {
if receipt["truncated"] != true {
t.Errorf("an incomplete receipt must also set truncated:true, got %v", receipt)
}
if w, _ := receipt["truncation_warning"].(string); w == "" {
t.Error("an incomplete receipt must carry a truncation_warning telling the caller what to do")
}
} else if _, has := receipt["truncated"]; has {
t.Errorf("a complete receipt must not carry a truncation marker, got %v", receipt)
}
// The file must actually hold the payload, not the receipt.
written, readErr := os.ReadFile(filepath.Join(dir, "out.json"))
if readErr != nil {
t.Fatalf("output file not written: %v", readErr)
}
var payload map[string]interface{}
if err := json.Unmarshal(written, &payload); err != nil {
t.Fatalf("output file is not JSON: %v", err)
}
if _, has := payload["ranges"]; !has {
t.Errorf("file should hold the data payload, got %s", written)
}
if n, _ := receipt["bytes_written"].(float64); int(n) != len(written) {
t.Errorf("bytes_written = %v, file is %d bytes", receipt["bytes_written"], len(written))
}
})
}
}

View File

@@ -282,6 +282,102 @@ func TestStylesPut_CoalescesSameStyleRanges(t *testing.T) {
t.Fatalf("range = %v, want A1:F5", input["range"])
}
})
// The cases above all pin that adjacent ranges DO fuse. The dangerous
// direction is the other one: coalescing rewrites a declarative spec into
// bigger rectangles, so a too-generous adjacency rule would paint cells the
// caller never named — silently, and only visible in the finished sheet.
// Widening the `+1` touch test in union() to `+2` passes every test above.
t.Run("a one-row gap is not fused across", func(t *testing.T) {
t.Parallel()
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": []interface{}{
map[string]interface{}{"range": "A1:C1", "font_weight": "bold"},
map[string]interface{}{"range": "A3:C3", "font_weight": "bold"},
}}},
}), testToken)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ops) != 2 {
t.Fatalf("ops=%d, want 2 — row 2 was never named and must not be styled", len(ops))
}
})
t.Run("a one-column gap is not fused across", func(t *testing.T) {
t.Parallel()
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": []interface{}{
map[string]interface{}{"range": "A1:B5", "font_weight": "bold"},
map[string]interface{}{"range": "D1:E5", "font_weight": "bold"},
}}},
}), testToken)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ops) != 2 {
t.Fatalf("ops=%d, want 2 — column C was never named and must not be styled", len(ops))
}
})
// The general property behind both: whatever coalescing does to the shape
// of the stamps, the SET of cells it covers must be exactly the set the
// caller named. Checked over a mix of touching, overlapping and separated
// rectangles so it constrains the merge rule rather than one example.
t.Run("coverage is preserved exactly", func(t *testing.T) {
t.Parallel()
inputs := []string{
"A1:C1", "A2:C2", // touching vertically -> may fuse
"E1:F2", "E3:F4", // touching vertically, different block
"A5:C5", // separated from A2:C2 by row 3-4 in columns A-C
"B2:D3", // overlaps the first block
"H10:H10",
}
entries := make([]interface{}, 0, len(inputs))
for _, r := range inputs {
entries = append(entries, map[string]interface{}{"range": r, "font_weight": "bold"})
}
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": entries}},
}), testToken)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := map[[2]int]bool{}
for _, r := range inputs {
addRangeCells(t, want, r)
}
got := map[[2]int]bool{}
for _, op := range ops {
input := op.(map[string]interface{})["input"].(map[string]interface{})
addRangeCells(t, got, input["range"].(string))
}
for cell := range want {
if !got[cell] {
t.Errorf("cell %v was named but no stamp covers it", cell)
}
}
for cell := range got {
if !want[cell] {
t.Errorf("cell %v is stamped but was never named by the caller", cell)
}
}
})
}
// addRangeCells records every (col,row) an A1 rectangle covers, so a test can
// compare what a spec named against what the expanded stamps actually touch.
func addRangeCells(t *testing.T, set map[[2]int]bool, rangeStr string) {
t.Helper()
c1, r1, c2, r2, err := workbookCreateStyleRangeBounds(rangeStr)
if err != nil {
t.Fatalf("bad range %q in test data: %v", rangeStr, err)
}
for c := c1; c <= c2; c++ {
for r := r1; r <= r2; r++ {
set[[2]int{c, r}] = true
}
}
}
// TestTypedCellsHabitualKeys pins the typed --cells cell-object fixes