mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
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:
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user