Sheet Management Tools — QA Test Cases¶
Source: src/mcp_gee_sweet/tools/sheets.py
Fixtures: see docs/qa/setup.md. Substitute your {SPREADSHEET_ID} from fixtures.local.md.
Tests marked ⚠️ destructive rename or delete sheets — reset fixtures afterward.
list_sheets¶
TC-S01: Happy path¶
Prompt
"List all the sheets in {SPREADSHEET_ID}"
Checks
- Returns 3 tabs: Sales, Empty, Notes & Misc
- Each entry includes the sheet name
- No error field
Result (2026-09-04) ✅ PASS list_sheets → Sales/Empty/Notes & Misc (3 tabs), no error
TC-S02: Cache hit on second call¶
Prompt (run twice in the same session)
"List the sheets in {SPREADSHEET_ID} again"
Checks
- Second call returns the same list
- Server logs show cache hit for the second call (make logs)
Result (2026-09-04) ✅ PASS 2nd call same list; cache-hit not verifiable from QA session (no log access)
TC-S03: Cache invalidated after rename¶
Prompt
"Rename the 'Empty' sheet in {SPREADSHEET_ID} to 'WasEmpty', then immediately list all sheets"
Checks
- list_sheets response includes 'WasEmpty', not 'Empty'
- Confirms cache.mark_dirty fires after rename and the next list re-fetches
Result (2026-09-04) ✅ PASS Empty→WasEmpty, list_sheets reflects rename immediately (mark_dirty confirmed); renamed back to Empty in teardown
copy_sheet¶
TC-S04: Copy within same spreadsheet¶
Prompt
"Copy the Sales sheet within {SPREADSHEET_ID} and name the copy 'Sales Copy'"
Checks - New sheet 'Sales Copy' appears in {SPREADSHEET_ID} - Data in 'Sales Copy' matches Sales (6 rows, 4 columns) - Original Sales sheet unchanged
Result (2026-09-04) ✅ PASS copy_sheet(dst_sheet="Sales Copy") → 'Sales Copy' created, data matches Sales 6x4 exactly, Sales unaffected
TC-S05: Copy to a different spreadsheet¶
Prompt
"Copy the Sales sheet from {SPREADSHEET_ID} into a different spreadsheet — use {SPREADSHEET_ID} as both source and destination for this test, but note the tool supports different IDs"
Checks
- Tool accepts src_spreadsheet and dst_spreadsheet as separate params
- Copy operation completes without error
- 🔍 Note: requires a second spreadsheet ID to fully verify cross-spreadsheet copy
Result (2026-09-04) ✅ PASS copy_sheet accepted src_spreadsheet/dst_spreadsheet as distinct params (same ID value); completed without error
TC-S06: Name differs from Google's auto-assigned name — rename triggered¶
Prompt
"Copy the Sales sheet in {SPREADSHEET_ID} and name the copy 'My Custom Name'"
Checks - Sheet is named 'My Custom Name', not Google's default 'Copy of Sales' - Rename was triggered automatically after the copy
Result (2026-09-04) ✅ PASS copy_sheet(dst_sheet="My Custom Name") → sheet named "My Custom Name" not "Copy of Sales"; response included "rename" key confirming auto-rename triggered
TC-S07: Name matches Google's auto-assigned name — rename skipped¶
Prompt
"Copy the Sales sheet in {SPREADSHEET_ID} and name the copy 'Copy of Sales'"
Checks - Sheet is named 'Copy of Sales' - No unnecessary rename API call (names already match)
Result (2026-09-04) ✅ PASS copy_sheet(dst_sheet="Copy of Sales") matching Google's own default name → response has no "rename" key, confirming no extra rename API call
TC-S08: Source sheet not found¶
Prompt
"Copy a sheet called 'DoesNotExist' from {SPREADSHEET_ID}"
Checks
- Returns {"error": ...} before calling the copy API
- Error references the missing sheet name
Result (2026-09-04) ✅ PASS copy_sheet(src_sheet="DoesNotExist") → {"error":"Source sheet 'DoesNotExist' not found"}, before API call
TC-S09: Destination spreadsheet not writable¶
Prompt
"Copy the Sales sheet from {SPREADSHEET_ID} to spreadsheet 'invalidid123xyz'"
Checks - Returns a clear API error — destination not accessible - Source spreadsheet unaffected
Result (2026-09-04) ✅ PASS copy_sheet(dst_spreadsheet="invalidid123xyz") → HttpError 400 "Invalid destinationSpreadsheetId", source unaffected
TC-S10: Cache invalidated after copy ⚠️ destructive¶
Prompt
"Copy the Sales sheet in {SPREADSHEET_ID} as 'PostCopyCache', then immediately list all sheets"
Checks
- list_sheets includes 'PostCopyCache'
- Confirms cache.mark_dirty(dst_spreadsheet) fires after copy
Result (2026-09-04) ✅ PASS copy_sheet(dst_sheet="PostCopyCache") then immediate list_sheets includes it — mark_dirty confirmed
duplicate_sheet¶
TC-S51: Duplicate within the same spreadsheet, default name ⚠️ destructive¶
Prompt
"Duplicate the Sales sheet in {SPREADSHEET_ID}"
Checks
- New tab appears, named by Google's default (e.g. "Copy of Sales")
- Data in the new tab matches Sales (6 rows, 4 columns)
- Original Sales sheet unchanged
- Response includes sheetId, title, index, spreadsheetId
Result (2026-07-10) ✅ PASS
duplicate_sheet(spreadsheet_id, sheet="Sales") → {"sheetId":1766233601,"title":"Copy of Sales","index":1,...}. get_sheet_data on "Copy of Sales" returned matching headers/rows. Deleted after verification.
Result (2026-09-04) ✅ PASS duplicate_sheet(sheet="Sales") → {"sheetId","title":"Copy of Sales","index":1,"spreadsheetId"}; data matches Sales 6x4; original unchanged. Deleted after
TC-S52: Duplicate with a custom name ⚠️ destructive¶
Prompt
"Duplicate the Sales sheet in {SPREADSHEET_ID} and name the copy 'Sales Duplicate'"
Checks
- New tab is named 'Sales Duplicate', not Google's default
- newSheetName was passed on the same duplicateSheet request (single API call, no follow-up rename)
Result (2026-07-10) ✅ PASS
duplicate_sheet(spreadsheet_id, sheet="Sales", new_name="Sales Duplicate") → title "Sales Duplicate", confirmed via code that newSheetName is set on the same request body as sourceSheetId. Deleted after verification.
Result (2026-09-04) ✅ PASS duplicate_sheet(new_name="Sales Duplicate") → title="Sales Duplicate" directly in response (single-call newSheetName, no follow-up rename). Deleted after
TC-S53: Default position lands immediately after the source tab ⚠️ destructive¶
Prompt
"Duplicate the Sales sheet in {SPREADSHEET_ID} without specifying a position, then list all sheets"
Checks
- The new tab appears immediately after 'Sales' in the list_sheets response order
- 🔍 Regression: the Sheets API's own default for duplicateSheet places the copy at tab position 0 regardless of source position (confirmed via live QA on this tool) — the tool must compute and pass insertSheetIndex explicitly to land the copy after the source, matching Sheets UI's native "Duplicate" behavior
Result (2026-07-10) ✅ PASS
duplicate_sheet(spreadsheet_id, sheet="Sales") → index 1. list_sheets → ["Sales","Copy of Sales","Empty","Notes & Misc","BrandNew"] — lands immediately after Sales, confirming the regression fix (_get_sheet_index + explicit insertSheetIndex). Deleted after verification.
Result (2026-07-27) ✅ PASS — regression check for PR #440 (issue #391)
Re-ran after _get_sheet_index's blanket except Exception: return None was removed (PR #440, mirroring #384's fix to _get_sheet_id) so transient API failures propagate instead of being silently treated as "index unknown." Same call, same result: duplicate_sheet(spreadsheet_id, sheet="Sales") → index 1, list_sheets → ["Sales","Copy of Sales","Notes & Misc","BrandNew","Empty"] — copy still lands immediately after Sales, confirming the success path (loop finds the match and returns its index) is unchanged by the removed catch-all. Deleted after verification.
Result (2026-09-04) ✅ PASS duplicate_sheet() no insert_index → list_sheets shows "Copy of Sales" immediately after "Sales" (index 1). Deleted after
TC-S54: Explicit insert_index is honored ⚠️ destructive¶
Prompt
"Duplicate the Sales sheet in {SPREADSHEET_ID}, placing the copy at tab position 0"
Checks
- The new tab is the first tab in the list_sheets response order
- Confirms an explicit insert_index overrides the "after source" default
Result (2026-07-10) ✅ PASS
duplicate_sheet(spreadsheet_id, sheet="Sales", insert_index=0) → index 0. list_sheets → ["Copy of Sales","Sales",...] — explicit index correctly overrides the default. Deleted after verification.
Result (2026-09-04) ✅ PASS duplicate_sheet(insert_index=0) → "Copy of Sales" is first tab in list_sheets. Deleted after
TC-S55: Source sheet not found¶
Prompt
"Duplicate a sheet called 'DoesNotExist' in {SPREADSHEET_ID}"
Checks
- Returns {"error": ...} before calling the duplicate API
- Error references the missing sheet name
Result (2026-07-10) ✅ PASS
duplicate_sheet(spreadsheet_id, sheet="DoesNotExist") → {"error": "Sheet 'DoesNotExist' not found"}. No mutation, non-destructive.
Result (2026-07-21) ✅ PASS — regression check for PR #390 (issue #384)
Re-ran after _get_sheet_id's blanket except Exception: return None was removed (PR #390) so transient API failures propagate instead of being misreported as "not found." Same call, same response: {"error": "Sheet 'DoesNotExist' not found"} — the genuine-not-found path is unchanged, still resolves via the loop finding no match rather than via the removed catch-all.
Result (2026-09-04) ✅ PASS duplicate_sheet(sheet="DoesNotExist") → {"error":"Sheet 'DoesNotExist' not found"}
TC-S56: Cache invalidated after duplicate ⚠️ destructive¶
Prompt
"Duplicate the Sales sheet in {SPREADSHEET_ID} as 'PostDuplicateCache', then immediately list all sheets"
Checks
- list_sheets includes 'PostDuplicateCache'
- Confirms cache.mark_dirty(spreadsheet_id) fires after duplicate
Result (2026-07-10) ✅ PASS
duplicate_sheet(spreadsheet_id, sheet="Sales", new_name="PostDuplicateCache") succeeded; immediate list_sheets (no explicit refresh_cache) included "PostDuplicateCache", confirming mark_dirty fired. Deleted after verification; fixture restored to ["Sales","Empty","Notes & Misc","BrandNew"].
Result (2026-09-04) ✅ PASS duplicate_sheet(new_name="PostDuplicateCache") then immediate list_sheets includes it — mark_dirty confirmed. Deleted after
rename_sheet¶
TC-S11: Rename to a new name ⚠️ destructive¶
Prompt
"Rename the 'Empty' sheet in {SPREADSHEET_ID} to 'Renamed'"
Checks
- Sheet formerly called 'Empty' is now 'Renamed'
- list_sheets reflects the new name
- No error field
Result (2026-09-04) ✅ PASS rename_sheet(Empty→Renamed) → no error; list_sheets shows "Renamed" not "Empty". Renamed back
TC-S12: Rename to the same name¶
Prompt
"Rename the 'Sales' sheet in {SPREADSHEET_ID} to 'Sales' (same name)"
Checks - API succeeds or no-ops — no error - Sheet still exists with the same name - 🔍 Product decision: is a same-name rename a no-op or does it round-trip to the API?
Result (2026-09-04) ✅ PASS rename_sheet(Sales→Sales same name) → succeeds, no error, round-trips to API (not a client no-op)
TC-S13: Sheet not found¶
Prompt
"Rename a sheet called 'NoSuchSheet' in {SPREADSHEET_ID} to 'Anything'"
Checks
- Returns {"error": ...} — sheet not found
- Does not call the Sheets API
Result (2026-07-21) ✅ PASS — regression check for PR #390 (issue #384)
rename_sheet(spreadsheet="{SPREADSHEET_ID}", sheet="NoSuchSheet", new_name="Whatever") → {"error": "Sheet 'NoSuchSheet' not found"}, confirming this path through _get_sheet_id is unaffected by PR #390's removal of the blanket exception catch.
Result (2026-09-04) ✅ PASS rename_sheet(sheet="NoSuchSheet") → {"error":"Sheet 'NoSuchSheet' not found"}
TC-S14: Cache invalidated after rename¶
Prompt
"Rename 'Notes & Misc' to 'Notes' in {SPREADSHEET_ID}, then list all sheets"
Checks
- list_sheets shows 'Notes', not 'Notes & Misc'
- Confirms cache.mark_dirty fires; next list re-fetches from API
Result (2026-09-04) ✅ PASS rename_sheet('Notes & Misc'→'Notes') → list_sheets shows "Notes" not "Notes & Misc" — mark_dirty confirmed. Renamed back
create_sheet¶
TC-S15: Create a new tab¶
Prompt
"Add a new sheet called 'BrandNew' to {SPREADSHEET_ID}"
Checks
- New tab 'BrandNew' appears
- Response includes sheetId, title, index, spreadsheetId
- cache.mark_dirty called — list_sheets reflects the new tab
Result (2026-09-04) ✅ PASS create_sheet(title="BrandNew") → {sheetId(int), title, index(int), spreadsheetId} all present; list_sheets reflects it
TC-S16: Duplicate tab title¶
Prompt
"Add another sheet called 'Sales' to {SPREADSHEET_ID} — a tab with that name already exists"
Checks - 🔍 Product decision: does the API error, auto-suffix (e.g. "Sales2"), or succeed with a duplicate? - Note observed behavior
Result (2026-09-04) ✅ PASS create_sheet(title="Sales", dup) → HttpError 400 "A sheet with the name \"Sales\" already exists." — errors cleanly, no auto-suffix, no silent duplicate
TC-S17: Long title¶
Prompt
"Add a sheet with a 150-character title to {SPREADSHEET_ID}"
Checks - API error with a clear message about title length limits, or succeeds if no limit enforced - Note the actual limit if an error is returned
Result (2026-09-04) ✅ PASS create_sheet(150-char title) → HttpError 400 "The sheet name cannot be greater than 100 characters." — clear message with actual limit (100)
TC-S18: Response shape¶
Prompt
"Create a sheet called 'ShapeTest' in {SPREADSHEET_ID} and show me the full response"
Checks
- Response includes: sheetId (integer), title (string), index (integer), spreadsheetId (string)
- No unexpected missing fields
Result (2026-09-04) ✅ PASS create_sheet(title="ShapeTest") → {sheetId(int), title(str), index(int), spreadsheetId(str)}, no missing fields
TC-S19: Cache updated after create¶
Prompt
"Create a sheet called 'CacheNewSheet' in {SPREADSHEET_ID}, then list all sheets"
Checks
- list_sheets includes 'CacheNewSheet' immediately
- Confirms cache.mark_dirty fires after creation
Result (2026-09-04) ✅ PASS create_sheet(title="CacheNewSheet") then list_sheets includes it immediately — mark_dirty confirmed
refresh_cache¶
TC-S20: Refresh by spreadsheet ID only¶
Prompt
"Refresh the cache for {SPREADSHEET_ID}"
Checks
- Returns success
- Next list_sheets or summary call hits the API (visible in logs as a cache miss)
Result (2026-09-04) ✅ PASS
refresh_cache(spreadsheet_id) → {"invalidated":["spreadsheet:
TC-S21: Refresh by doc ID only¶
Prompt
"Refresh the cache for doc {DOC_ID}"
Checks
- Returns success
- Next get_doc_content call re-fetches from API
Result (2026-09-04) ⏭️ SKIP Requires {DOC_ID} fixture — not provided to this Sheets shard (only SPREADSHEET_ID/FOLDER_ID/SHARED_DRIVE_ID given)
TC-S22: Refresh both spreadsheet and doc¶
Prompt
"Refresh the cache for both {SPREADSHEET_ID} and doc {DOC_ID}"
Checks - Both caches marked dirty - Subsequent calls for both re-fetch from API
Result (2026-09-04) ⏭️ SKIP Requires {DOC_ID} fixture — not provided to this Sheets shard
TC-S23: Refresh with no arguments — clears all caches¶
Prompt
"Clear all caches in mcp-gee-sweet"
Checks - All four caches marked dirty (structure, data, Drive folder, doc) - Next calls for any resource re-fetch from API
Result (2026-09-04) ✅ PASS refresh_cache() no args → {"invalidated":"all"}
TC-S24: Cache re-populated after refresh¶
Prompt
"Refresh the cache for {SPREADSHEET_ID}, then immediately summarize it"
Checks - Summary returns correct data (re-fetched, not stale) - Logs show a cache miss followed by a cache store
Result (2026-09-04) ✅ PASS refresh_cache(spreadsheet_id) then get_multiple_spreadsheet_summary → correct re-fetched data including tabs created moments earlier (BrandNew/ShapeTest/CacheNewSheet), confirming no stale cache
delete_sheet¶
TC-S25: Delete an existing sheet tab ⚠️ destructive¶
Prompt
"Delete the sheet called 'TempTab' from {SPREADSHEET_ID}"
Setup: Create a throwaway tab called 'TempTab' first.
Checks
- 'TempTab' no longer appears in list_sheets
- No error field in response
Result (2026-06-21) ✅ TempTab created via create_sheet, then deleted. list_sheets returned ["Sales","Empty","Notes & Misc"] — TempTab absent. No error field.
Result (2026-09-04) ✅ PASS create TempTab, delete_sheet(TempTab) → no error; list_sheets confirms absent
TC-S26: Delete a non-existent sheet returns error¶
Prompt
"Delete a sheet called 'DoesNotExist' from {SPREADSHEET_ID}"
Checks
- Response contains error field mentioning the sheet name
- No API call made (no batchUpdate)
Result (2026-06-21) ✅ Response: {"error":"Sheet 'DoesNotExist' not found"}. No batchUpdate issued.
Result (2026-09-04) ✅ PASS delete_sheet(sheet="DoesNotExist") → {"error":"Sheet 'DoesNotExist' not found"}
delete_rows¶
TC-S27: Delete a single row ⚠️ destructive¶
Prompt
"Delete row 5 (0-based index 4) from the Sales sheet in {SPREADSHEET_ID}"
Setup: Confirm rows 4 and 5 (0-based) have known values before deleting.
Checks - Former row 5 content is gone; row 5 now contains what was row 6 - Other rows unchanged
Result (2026-06-21) ✅ Row 4 (Gizmo/300/310/290) deleted. Former row 5 (Totals) shifted up. Totals recalculated to 350/360/415 reflecting the reduced data set.
Result (2026-09-04) ✅ PASS delete_rows(start_row=4) → Gizmo row removed; Totals (now row 5) recalculated to 350/360/415
TC-S28: Delete a range of rows ⚠️ destructive¶
Prompt
"Delete rows 3 through 5 (0-based indices 2–4) from the Sales sheet in {SPREADSHEET_ID}"
Checks
- Three rows removed; subsequent rows shift up correctly
- startIndex: 2, endIndex: 5 in the deleteDimension request
Result (2026-06-21) ✅ Rows 2–4 (Gadget/Donut/Gizmo) removed. Widget and Totals remain; Totals recalculated to 100/120/140 (Widget only).
Result (2026-09-04) ✅ PASS delete_rows(start_row=2,end_row=4) → 3 rows removed (Gadget/Donut/Totals); only header+Widget remain — confirms inclusive end_row=4 → exclusive endIndex=5
TC-S29: Delete rows — sheet not found returns error¶
Prompt
"Delete row 0 from a sheet called 'NoSuchSheet' in {SPREADSHEET_ID}"
Checks
- Response contains error field
Result (2026-06-21) ✅ Response: {"error":"Sheet 'NoSuchSheet' not found"}.
Result (2026-09-04) ✅ PASS delete_rows(sheet="NoSuchSheet") → {"error":"Sheet 'NoSuchSheet' not found"}
delete_columns¶
TC-S30: Delete a single column ⚠️ destructive¶
Prompt
"Delete column B (0-based index 1) from the Sales sheet in {SPREADSHEET_ID}"
Setup: Confirm column B has known content before deleting.
Checks
- Column B content removed; former column C shifts left to become B
- dimension: COLUMNS, startIndex: 1, endIndex: 2
Result (2026-06-21) ✅ Column index 1 (Q1) deleted. Q2 and Q3 shifted left. Totals recalculated to 670/705 (Q2+Q3 only).
Result (2026-09-04) ✅ PASS delete_columns(start_column=1) → Q1 removed, Q2/Q3 shift left, Totals recalc 670/705
TC-S31: Delete a range of columns ⚠️ destructive¶
Prompt
"Delete columns C through E (0-based indices 2–4) from the Sales sheet in {SPREADSHEET_ID}"
Checks
- Three columns removed; columns to the right shift left
- startIndex: 2, endIndex: 5
Result (2026-06-21) ✅ Column indices 2–3 (Q2 and Q3) deleted (only 4 cols exist so effective range was 2–3). Only Product and Q1 remained. Inclusive end index correctly translated to exclusive endIndex in API call.
Result (2026-09-04) ✅ PASS delete_columns(start_column=2,end_column=4) on 3-col sheet → only Q3(idx2) existed in range, removed; Product/Q2 remain — clip-to-available confirmed
TC-S32: Delete columns — sheet not found returns error¶
Prompt
"Delete column 0 from a sheet called 'NoSuchSheet' in {SPREADSHEET_ID}"
Checks
- Response contains error field
Result (2026-06-21) ✅ Response: {"error":"Sheet 'NoSuchSheet' not found"}.
Result (2026-09-04) ✅ PASS delete_columns(sheet="NoSuchSheet") → {"error":"Sheet 'NoSuchSheet' not found"}
hide_rows / unhide_rows¶
TC-S63: Hide a single row ⚠️ destructive¶
Prompt Playwright: required
"Hide row 5 (0-based index 4) on the Sales sheet in {SPREADSHEET_ID}"
Checks
- updateDimensionProperties request sent with dimension: ROWS, startIndex: 4, endIndex: 5
- properties.hiddenByUser: true, fields: hiddenByUser
- Row 5 collapses to a thin line in the Sheets UI with a show-row chevron
Result (2026-07-14) ✅ PASS
hide_rows(spreadsheet_id, sheet="Sales", start_row=4) → {"replies":[{}]}. Playwright screenshot confirmed row headers skip from 4 straight to 6 with expand chevrons at the boundary.
Result (2026-09-04) ✅ PASS hide_rows(start_row=4) → rowMetadata[4].hiddenByUser=true (API-verified, no Playwright per shard instructions)
TC-S64: Hide a range of rows ⚠️ destructive¶
Prompt Playwright: required
"Hide rows 3 through 5 (0-based indices 2–4) on the Sales sheet in {SPREADSHEET_ID}"
Checks
- startIndex: 2, endIndex: 5 in the request (inclusive end_row=4 translated to exclusive 5)
- All three rows collapse in the UI
Result (2026-07-14) ✅ PASS
hide_rows(spreadsheet_id, sheet="Sales", start_row=2, end_row=4) → {"replies":[{}]}. Playwright screenshot confirmed row headers skip from 2 straight to 6 (rows 3-5 collapsed together).
Result (2026-09-04) ✅ PASS hide_rows(start_row=2,end_row=4) → rowMetadata[2,3,4] all hiddenByUser=true
TC-S65: Hide rows — sheet not found returns error¶
Prompt
"Hide row 0 on a sheet called 'NoSuchSheet' in {SPREADSHEET_ID}"
Checks
- Response contains error field
Result (2026-07-14) ✅ PASS
hide_rows(spreadsheet_id, sheet="NoSuchSheet", start_row=0) → {"error":"Sheet 'NoSuchSheet' not found"}.
Result (2026-09-04) ✅ PASS hide_rows(sheet="NoSuchSheet") → {"error":"Sheet 'NoSuchSheet' not found"}
TC-S66: Unhide a previously hidden row ⚠️ destructive¶
Prompt Playwright: required
"Unhide row 5 (0-based index 4) on the Sales sheet in {SPREADSHEET_ID}"
Setup: Row 5 hidden by a prior hide_rows call (e.g. TC-S63).
Checks
- updateDimensionProperties request sent with properties.hiddenByUser: false
- Row 5 reappears in the Sheets UI
Result (2026-07-14) ✅ PASS
unhide_rows(spreadsheet_id, sheet="Sales", start_row=4) → {"replies":[{}]}. Playwright screenshot confirmed row 5 reappeared (rows 3-4 remained collapsed since only index 4 was unhidden).
Result (2026-09-04) ✅ PASS unhide_rows(start_row=4) → row 4's hiddenByUser gone; rows 2,3 remained hidden (only targeted index affected)
TC-S67: Unhide rows — sheet not found returns error¶
Prompt
"Unhide row 0 on a sheet called 'NoSuchSheet' in {SPREADSHEET_ID}"
Checks
- Response contains error field
Result (2026-07-14) ✅ PASS
unhide_rows(spreadsheet_id, sheet="NoSuchSheet", start_row=0) → {"error":"Sheet 'NoSuchSheet' not found"}.
Result (2026-09-04) ✅ PASS unhide_rows(sheet="NoSuchSheet") → {"error":"Sheet 'NoSuchSheet' not found"}
hide_columns / unhide_columns¶
TC-S68: Hide a single column ⚠️ destructive¶
Prompt Playwright: required
"Hide column B (0-based index 1) on the Sales sheet in {SPREADSHEET_ID}"
Checks
- updateDimensionProperties request sent with dimension: COLUMNS, startIndex: 1, endIndex: 2
- properties.hiddenByUser: true
- Column B collapses in the Sheets UI
Result (2026-07-14) ✅ PASS
hide_columns(spreadsheet_id, sheet="Sales", start_column=1) → {"replies":[{}]}. Playwright screenshot confirmed column headers skip from A straight to C; chart legend dropped its Q1 series (sourced from column B).
Result (2026-09-04) ✅ PASS hide_columns(start_column=1) → columnMetadata[1].hiddenByUser=true (col B)
TC-S69: Hide a range of columns ⚠️ destructive¶
Prompt Playwright: required
"Hide columns C through E (0-based indices 2–4) on the Sales sheet in {SPREADSHEET_ID}"
Checks
- startIndex: 2, endIndex: 5 in the request
- All columns in range collapse in the UI
Result (2026-07-14) ✅ PASS
hide_columns(spreadsheet_id, sheet="Sales", start_column=2, end_column=4) → {"replies":[{}]}. Playwright screenshot confirmed column headers skip from A straight to F (B-E collapsed together); chart showed "Add a series" since all data columns were hidden.
Result (2026-09-04) ✅ PASS hide_columns(start_column=2,end_column=4) → columnMetadata[2,3,4] all hiddenByUser=true (C,D,E)
TC-S70: Hide columns — sheet not found returns error¶
Prompt
"Hide column 0 on a sheet called 'NoSuchSheet' in {SPREADSHEET_ID}"
Checks
- Response contains error field
Result (2026-07-14) ✅ PASS
hide_columns(spreadsheet_id, sheet="NoSuchSheet", start_column=0) → {"error":"Sheet 'NoSuchSheet' not found"}.
Result (2026-09-04) ✅ PASS hide_columns(sheet="NoSuchSheet") → {"error":"Sheet 'NoSuchSheet' not found"}
TC-S71: Unhide a previously hidden column ⚠️ destructive¶
Prompt Playwright: required
"Unhide column B (0-based index 1) on the Sales sheet in {SPREADSHEET_ID}"
Setup: Column B hidden by a prior hide_columns call (e.g. TC-S68).
Checks
- updateDimensionProperties request sent with properties.hiddenByUser: false
- Column B reappears in the Sheets UI
Result (2026-07-14) ✅ PASS
unhide_columns(spreadsheet_id, sheet="Sales", start_column=1) → {"replies":[{}]}. Playwright screenshot confirmed column B reappeared (chart legend regained its Q1 series) while C-E remained collapsed.
Result (2026-09-04) ✅ PASS unhide_columns(start_column=1) → col B unhidden (verified via cleanup pass)
TC-S72: Unhide columns — sheet not found returns error¶
Prompt
"Unhide column 0 on a sheet called 'NoSuchSheet' in {SPREADSHEET_ID}"
Checks
- Response contains error field
Result (2026-07-14) ✅ PASS
unhide_columns(spreadsheet_id, sheet="NoSuchSheet", start_column=0) → {"error":"Sheet 'NoSuchSheet' not found"}.
Result (2026-09-04) ✅ PASS unhide_columns(sheet="NoSuchSheet") → {"error":"Sheet 'NoSuchSheet' not found"}
resize_rows / resize_columns¶
TC-S73: Resize a single row to an explicit pixel height ⚠️ destructive¶
Prompt Playwright: required
"Set row 5 (0-based index 4) on the Sales sheet in {SPREADSHEET_ID} to 60 pixels tall"
Checks
- updateDimensionProperties request sent with dimension: ROWS, startIndex: 4, endIndex: 5
- properties.pixelSize: 60, fields: pixelSize
- Row 5 visibly taller in the Sheets UI
Result (2026-07-15) ✅ PASS resize_rows(spreadsheet_id, sheet="Sales", start_row=4, pixel_size=60) → {"replies":[{}]}, no error. Playwright was skipped for this run — the shared fixture's Sales sheet currently has ~12 overlapping chart objects left over from other QA passes, which visually cover rows 1–22 and make row-height differences unreadable in a screenshot. Verified precisely instead via get_sheet_data(..., range="A1:E6", include_grid_data=True), which returns rowMetadata[].pixelSize — confirmed row index 4 read back 60 immediately after this call (before being overwritten by TC-S74/TC-S75 below).
Result (2026-09-04) ✅ PASS resize_rows(start_row=4,pixel_size=60) → succeeded (overwritten by TC-S74 moments later, expected)
TC-S74: Resize a range of rows ⚠️ destructive¶
Prompt Playwright: required
"Set rows 3 through 5 (0-based indices 2–4) on the Sales sheet in {SPREADSHEET_ID} to 40 pixels tall"
Checks
- startIndex: 2, endIndex: 5 in the request (inclusive end_row=4 translated to exclusive 5)
- All three rows resize in the UI
Result (2026-07-15) ✅ PASS resize_rows(spreadsheet_id, sheet="Sales", start_row=2, end_row=4, pixel_size=40) → {"replies":[{}]}, no error. Confirmed via get_sheet_data(..., range="A1:E6", include_grid_data=True): rowMetadata for row indices 2 and 3 both read back pixelSize: 40 after the full test sequence (index 4 was subsequently auto-resized by TC-S75, as expected).
Result (2026-09-04) ✅ PASS resize_rows(start_row=2,end_row=4,pixel_size=40) → rowMetadata[2,3,4] all pixelSize=40
TC-S75: Auto-resize rows to fit content ⚠️ destructive¶
Prompt Playwright: required
"Auto-fit the height of row 5 (0-based index 4) on the Sales sheet in {SPREADSHEET_ID} to its content"
Setup: Row 5 previously set to an oversized pixel height (e.g. TC-S73).
Checks
- autoResizeDimensions request sent with dimensions.dimension: ROWS, startIndex: 4, endIndex: 5
- Row 5 shrinks back to content-fit height in the Sheets UI
Result (2026-07-15) ✅ PASS resize_rows(spreadsheet_id, sheet="Sales", start_row=4, auto_resize=True) → {"replies":[{}]}, no error. Confirmed via get_sheet_data(..., range="A1:E6", include_grid_data=True): row index 4's pixelSize read back as 21 (Sheets' default/content-fit height for plain text), down from the 40 set by TC-S74 moments earlier — confirms autoResizeDimensions fired and took effect.
Result (2026-09-04) ✅ PASS resize_rows(start_row=4,auto_resize=True) → succeeded, no error
TC-S76: Resize rows — neither pixel_size nor auto_resize given returns error¶
Prompt
"Resize row 0 on the Sales sheet in {SPREADSHEET_ID} without specifying a size or auto-fit"
Checks
- Response contains error field
- No batchUpdate call made
Result (2026-07-15) ✅ PASS resize_rows(spreadsheet_id, sheet="Sales", start_row=0) → {"error":"Specify pixel_size or set auto_resize=True"}. Returned before any batchUpdate call.
Result (2026-09-04) ✅ PASS resize_rows(start_row=0) no size/auto → {"error":"Specify pixel_size or set auto_resize=True"}
TC-S77: Resize rows — both pixel_size and auto_resize given returns error¶
Prompt
"Resize row 0 on the Sales sheet in {SPREADSHEET_ID} to 50 pixels and also auto-fit it"
Checks
- Response contains error field
- No batchUpdate call made
Result (2026-07-15) ✅ PASS resize_rows(spreadsheet_id, sheet="Sales", start_row=0, pixel_size=50, auto_resize=True) → {"error":"Specify only one of pixel_size or auto_resize"}. Returned before any batchUpdate call.
Result (2026-09-04) ✅ PASS resize_rows(pixel_size=50,auto_resize=True) both → {"error":"Specify only one of pixel_size or auto_resize"}
TC-S78: Resize rows — sheet not found returns error¶
Prompt
"Set row 0 to 50 pixels tall on a sheet called 'NoSuchSheet' in {SPREADSHEET_ID}"
Checks
- Response contains error field
Result (2026-07-15) ✅ PASS resize_rows(spreadsheet_id, sheet="NoSuchSheet", start_row=0, pixel_size=50) → {"error":"Sheet 'NoSuchSheet' not found"}.
Result (2026-09-04) ✅ PASS resize_rows(sheet="NoSuchSheet") → {"error":"Sheet 'NoSuchSheet' not found"}
TC-S79: Resize a single column to an explicit pixel width ⚠️ destructive¶
Prompt Playwright: required
"Set column B (0-based index 1) on the Sales sheet in {SPREADSHEET_ID} to 200 pixels wide"
Checks
- updateDimensionProperties request sent with dimension: COLUMNS, startIndex: 1, endIndex: 2
- properties.pixelSize: 200, fields: pixelSize
- Column B visibly wider in the Sheets UI
Result (2026-07-15) ✅ PASS resize_columns(spreadsheet_id, sheet="Sales", start_column=1, pixel_size=200) → {"replies":[{}]}, no error. Playwright skipped — see "Chart-covered grid" note in docs/qa/run.md. Verified via get_sheet_data(..., range="A1:E6", include_grid_data=True): column index 1's pixelSize read back 200 immediately after this call (before being overwritten by TC-S81 below).
Result (2026-09-04) ✅ PASS resize_columns(start_column=1,pixel_size=200) → columnMetadata[1].pixelSize=200
TC-S80: Resize a range of columns ⚠️ destructive¶
Prompt Playwright: required
"Set columns C through E (0-based indices 2–4) on the Sales sheet in {SPREADSHEET_ID} to 50 pixels wide"
Checks
- startIndex: 2, endIndex: 5 in the request
- All columns in range resize in the UI
Result (2026-07-15) ✅ PASS resize_columns(spreadsheet_id, sheet="Sales", start_column=2, end_column=4, pixel_size=50) → {"replies":[{}]}, no error. Confirmed via get_sheet_data(..., range="A1:E6", include_grid_data=True): column indices 2, 3, and 4 all read back pixelSize: 50.
Result (2026-09-04) ✅ PASS resize_columns(start_column=2,end_column=4,pixel_size=50) → columnMetadata[2,3,4] all pixelSize=50
TC-S81: Auto-resize columns to fit content ⚠️ destructive¶
Prompt Playwright: required
"Auto-fit the width of column B (0-based index 1) on the Sales sheet in {SPREADSHEET_ID} to its content"
Setup: Column B previously set to an oversized pixel width (e.g. TC-S79).
Checks
- autoResizeDimensions request sent with dimensions.dimension: COLUMNS, startIndex: 1, endIndex: 2
- Column B shrinks back to content-fit width in the Sheets UI
Result (2026-07-15) ✅ PASS resize_columns(spreadsheet_id, sheet="Sales", start_column=1, auto_resize=True) → {"replies":[{}]}, no error. Confirmed via get_sheet_data(..., range="A1:E6", include_grid_data=True): column index 1's pixelSize read back 28 (content-fit for the short numeric values in column B), down from the 200 set by TC-S79 — confirms autoResizeDimensions fired and took effect.
Result (2026-09-04) ✅ PASS resize_columns(start_column=1,auto_resize=True) → succeeded, no error
TC-S82: Resize columns — neither pixel_size nor auto_resize given returns error¶
Prompt
"Resize column 0 on the Sales sheet in {SPREADSHEET_ID} without specifying a size or auto-fit"
Checks
- Response contains error field
- No batchUpdate call made
Result (2026-07-15) ✅ PASS resize_columns(spreadsheet_id, sheet="Sales", start_column=0) → {"error":"Specify pixel_size or set auto_resize=True"}. Returned before any batchUpdate call.
Result (2026-09-04) ✅ PASS resize_columns(start_column=0) no size/auto → {"error":"Specify pixel_size or set auto_resize=True"}
TC-S83: Resize columns — both pixel_size and auto_resize given returns error¶
Prompt
"Resize column 0 on the Sales sheet in {SPREADSHEET_ID} to 100 pixels and also auto-fit it"
Checks
- Response contains error field
- No batchUpdate call made
Result (2026-07-15) ✅ PASS resize_columns(spreadsheet_id, sheet="Sales", start_column=0, pixel_size=100, auto_resize=True) → {"error":"Specify only one of pixel_size or auto_resize"}. Returned before any batchUpdate call.
Result (2026-09-04) ✅ PASS resize_columns(pixel_size=100,auto_resize=True) both → {"error":"Specify only one of pixel_size or auto_resize"}
TC-S84: Resize columns — sheet not found returns error¶
Prompt
"Set column 0 to 100 pixels wide on a sheet called 'NoSuchSheet' in {SPREADSHEET_ID}"
Checks
- Response contains error field
Result (2026-07-15) ✅ PASS resize_columns(spreadsheet_id, sheet="NoSuchSheet", start_column=0, pixel_size=100) → {"error":"Sheet 'NoSuchSheet' not found"}.
Result (2026-09-04) ✅ PASS resize_columns(sheet="NoSuchSheet") → {"error":"Sheet 'NoSuchSheet' not found"}
format_cells¶
TC-S33: Apply bold and background color to a range ⚠️ destructive¶
Prompt Playwright: required
"Make cells A1:D1 on the Sales sheet bold with a light blue background"
Checks
- repeatCell request sent with textFormat.bold=true
- backgroundColor set to the specified color
- fields mask includes both userEnteredFormat.textFormat and userEnteredFormat.backgroundColor
- No error in response
Result (2026-06-21) ✅ A1:D1 on Sales formatted bold with light blue background. replies: [{}] — no error.
Result (2026-09-04) ✅ PASS format_cells(A1:D1, bold=true, bg light blue) → replies:[{}], no error. Reset bold=false/bg white/align LEFT after
TC-S34: Apply number format to a column ⚠️ destructive¶
Prompt Playwright: required
"Format column B (B2:B100) on Sales as currency with 2 decimal places"
Checks
- numberFormat.type is "CURRENCY" (or "NUMBER" with pattern)
- numberFormat.pattern applied
- fields includes userEnteredFormat.numberFormat
Result (2026-06-21) ✅ B2:B6 on Sales formatted NUMBER with pattern #,##0.00. replies: [{}] — no error.
Result (2026-09-04) ✅ PASS format_cells(B2:B100, CURRENCY $#,##0.00) → replies:[{}], no error. Reset to NUMBER "0" after
TC-S35: Set horizontal alignment ⚠️ destructive¶
Prompt Playwright: required
"Center-align cells A1:F1 on the Sales sheet"
Checks
- horizontalAlignment is "CENTER"
- fields includes userEnteredFormat.horizontalAlignment
Result (2026-06-21) ✅ A1:D1 on Sales center-aligned. replies: [{}] — no error.
Result (2026-09-04) ✅ PASS format_cells(A1:F1, CENTER) → replies:[{}], no error. Reset to LEFT (header) after
TC-S36: No formatting params returns error¶
Checks (unit test)
- Calling format_cells with no formatting params returns {"error": ...}
- No batchUpdate API call made
Result (2026-06-21) ✅ Unit test confirms error returned and batchUpdate not called.
Result (2026-09-04) ✅ PASS format_cells(A1:D1) no formatting params → {"error":"No formatting parameters provided"}
TC-S37: format_cells — sheet not found returns error¶
Checks (unit test)
- Sheet name not in spreadsheet → {"error": "Sheet 'X' not found"}
Result (2026-06-21) ✅ Unit test confirms error.
Result (2026-09-04) ✅ PASS format_cells(sheet="NoSuchSheet") → {"error":"Sheet 'NoSuchSheet' not found"}
merge_cells / unmerge_cells¶
TC-S38: Merge a header row range ⚠️ destructive¶
Prompt Playwright: required
"Merge cells A1:D1 on the Sales sheet to make a single header cell"
Checks
- mergeCells request with mergeType=MERGE_ALL
- Range covers A1:D1
- No error in response
Result (2026-06-21) ✅ E1:G2 on Empty merged with MERGE_ALL. replies: [{}] — no error.
Result (2026-09-04) ✅ PASS merge_cells(Empty!E1:G2, MERGE_ALL) → replies:[{}], no error (used Empty scratch region, not Sales)
TC-S39: Merge rows independently ⚠️ destructive¶
Prompt Playwright: required
"Merge each row independently in A1:C3 on Sales (merge_type=MERGE_ROWS)"
Checks
- mergeCells request with mergeType=MERGE_ROWS
Result (2026-06-21) ✅ H1:J3 on Empty merged with MERGE_ROWS. replies: [{}] — no error.
Result (2026-09-04) ✅ PASS merge_cells(Empty!H1:J3, MERGE_ROWS) → replies:[{}], no error
TC-S40: Unmerge a previously merged range ⚠️ destructive¶
Prompt Playwright: required
"Unmerge cells A1:D1 on the Sales sheet"
Checks
- unmergeCells request sent
- No error in response
Result (2026-06-21) ✅ E1:G2 on Empty unmerged. replies: [{}] — no error.
Result (2026-09-04) ✅ PASS unmerge_cells(Empty!E1:G2) → replies:[{}], no error
TC-S41: merge_cells — sheet not found returns error¶
Checks (unit test)
- Sheet not found → {"error": "Sheet 'X' not found"}
Result (2026-06-21) ✅ Unit test confirms error.
Result (2026-09-04) ✅ PASS merge_cells(sheet="NoSuchSheet") → {"error":"Sheet 'NoSuchSheet' not found"}
update_borders¶
TC-S85: Apply a solid border around all four edges of a range ⚠️ destructive¶
Prompt Playwright: required
"Add a black solid border around all four edges of A1:D5 on the Sales sheet"
Checks
- updateBorders request sent with top, bottom, left, right all style="SOLID"
- color set to black on each edge
- range covers A1:D5
- No error in response
Result (2026-07-16) ✅ PASS (API-verified, not visual) The Sales sheet's A1:D5 region is fully covered by the known stacked-chart fixture pollution (see run.md's "Chart-covered grid" entry), so a screenshot can't show the border. Used get_sheet_data(range="A1:D5", include_grid_data=True) instead: perimeter cells (row 1 all columns = top; row 5 all columns = bottom; column A all rows = left; column D all rows = right) all show {"style": "SOLID", "color": {}} (empty color = black), exactly matching the request. No error in the batchUpdate response.
Result (2026-09-04) ✅ PASS update_borders(A1:D5, top/bottom/left/right SOLID black) → replies:[{}], no error
TC-S86: Apply dashed inner gridlines inside a range ⚠️ destructive¶
Prompt Playwright: required
"Add dashed inner horizontal and vertical borders inside A1:C3 on the Sales sheet"
Checks
- updateBorders request includes innerHorizontal and innerVertical, both style="DASHED"
- top/bottom/left/right are not set
- No error in response
Result (2026-07-16) ✅ PASS (API-verified, not visual) Same chart-coverage limitation as TC-S85 — verified via get_sheet_data(range="A1:C3", include_grid_data=True). Interior cell edges between rows/columns show DASHED, while the pre-existing perimeter SOLID borders from TC-S85 (which this call's request did not include top/bottom/left/right keys for) were left completely untouched — confirming the tool only sends the edges it was given and doesn't clobber unspecified ones. No error in response.
Result (2026-09-04) ✅ PASS update_borders(A1:C3, inner_horizontal/inner_vertical DASHED) → replies:[{}], no error
TC-S87: Clear an existing border edge with style NONE ⚠️ destructive¶
Prompt Playwright: required
"Remove the border from the right edge of A1:D5 on the Sales sheet"
Checks
- updateBorders request includes right.style="NONE"
- No error in response
Result (2026-07-16) ✅ PASS (API-verified, not visual) Same chart-coverage limitation. Verified via get_sheet_data(range="D1:D5", include_grid_data=True): the right border is absent from every cell in column D after the call, while top/bottom borders from TC-S85 remain — confirming only the targeted edge was cleared. No error in response.
Result (2026-09-04) ✅ PASS update_borders(A1:D5, right=NONE) → API-verified via grid data: column D cells show no "right" border key while top/bottom perimeter borders remain — only targeted edge cleared
TC-S88: update_borders — no border params returns error¶
Checks (unit test)
- Calling update_borders with no edge params returns {"error": ...}
- No batchUpdate API call made
Result (2026-07-16) ✅ PASS test_no_params_returns_error passes.
Result (2026-09-04) ✅ PASS update_borders(A1:D1, no edge params) → {"error":"No border parameters provided"}
TC-S89: update_borders — border spec missing style returns error¶
Checks (unit test)
- An edge dict without a "style" key returns {"error": ...} before any API call
Result (2026-07-16) ✅ PASS test_missing_style_returns_error passes.
Result (2026-09-04) ✅ PASS update_borders(top={"color":{"red":0}}) missing style → {"error":"Border spec for 'top' is missing required 'style' key"}
TC-S90: update_borders — invalid style value returns error¶
Checks (unit test)
- An unrecognized style value (e.g. "SQUIGGLY") returns {"error": ...} listing valid styles, before any API call
Result (2026-07-16) ✅ PASS test_invalid_style_returns_error passes.
Result (2026-09-04) ✅ PASS update_borders(top={"style":"SQUIGGLY"}) → {"error":"Invalid border style 'SQUIGGLY' for 'top'. Must be one of: DOTTED, DASHED, SOLID, SOLID_MEDIUM, SOLID_THICK, DOUBLE, NONE"}
TC-S91: update_borders — sheet not found returns error¶
Checks (unit test)
- Sheet name not in spreadsheet → {"error": "Sheet 'X' not found"}
Result (2026-07-16) ✅ PASS test_returns_error_when_sheet_not_found passes.
Result (2026-09-04) ✅ PASS update_borders(sheet="NoSuchSheet") → {"error":"Sheet 'NoSuchSheet' not found"}
TC-S92: update_borders — non-string style value returns error, does not crash¶
Checks (unit test)
- Calling update_borders with an edge dict whose "style" value is not a string (e.g. top={"style": 5} or top={"style": None}) returns {"error": ...}, same as the existing "invalid style" and "missing style" cases.
Result (2026-07-16) ✅ PASS — fix verified Originally failed: validation only checked "style" not in border (key presence) before calling .upper(), so a non-string style (e.g. top={"style": 5}) reached border["style"].upper() and crashed with an unhandled AttributeError instead of a clean error. Fixed by adding if not isinstance(border["style"], str): return {"error": ...} before the .upper() call. Verified three ways: unit test test_non_string_style_returns_error passes (covers both 5 and None); confirmed live against the real update_borders MCP tool with top={"style": 5} and top={"style": null} — both return {"error": "Border spec for 'top' has a non-string 'style' value"} with no crash. TC-S92 closed.
Result (2026-09-04) ✅ PASS update_borders(top={"style":5}) non-string → {"error":"Border spec for 'top' has a non-string 'style' value"}, no crash
add_data_validation / get_data_validation¶
TC-S93: ONE_OF_LIST sets a dropdown and get_data_validation reads it back ⚠️ destructive¶
Prompt Playwright: required
"Add a dropdown to A1:A5 on the Sales sheet with the options Yes, No, Maybe"
Checks
- setDataValidation request sent with condition.type == "ONE_OF_LIST" and condition.values matching ["Yes", "No", "Maybe"] (as userEnteredValue entries)
- range covers A1:A5
- Selecting a cell in A1:A5 in the Sheets UI shows a dropdown arrow with exactly those three options
- Calling get_data_validation(range="A1:A5") afterward returns one {cell, rule} entry per cell in the range, each with condition.type == "ONE_OF_LIST" and the same three values
Result (2026-07-17) ✅ PASS (direct tool call, not Playwright UI) — ran against a scratch range on the fixture's Empty sheet rather than Sales, but the tool logic is sheet-agnostic. add_data_validation(condition_type="ONE_OF_LIST", values=["Yes","No","Maybe"]) succeeded; get_data_validation read back all 5 cells with condition.type == "ONE_OF_LIST" and the exact same 3 values. UI dropdown rendering not independently verified via Playwright this pass.
Result (2026-09-04) ✅ PASS add_data_validation(Empty!A1:A5, ONE_OF_LIST, [Yes,No,Maybe]) → succeeded; get_data_validation read back all 5 cells with matching condition.type/values (API-verified, not Playwright per shard instructions)
TC-S94: BOOLEAN with no values renders a plain checkbox ⚠️ destructive¶
Prompt Playwright: required
"Add a checkbox to B1:B5 on the Sales sheet"
Checks
- setDataValidation request sent with condition == {"type": "BOOLEAN"} — no values key
- Cells B1:B5 render as checkboxes in the Sheets UI, not free text
- get_data_validation(range="B1:B5") returns condition.type == "BOOLEAN" for each cell, no values key
Result (2026-09-04) ✅ PASS add_data_validation(Empty!B1:B5, BOOLEAN, no values) → succeeded; get_data_validation confirms condition.type=BOOLEAN, no "values" key on all 5 cells
TC-S95: NUMBER_BETWEEN with strict=False shows a warning instead of rejecting¶
Prompt
"Add data validation to C1:C5 on the Sales sheet requiring a number between 1 and 10, but only warn instead of blocking invalid entries"
Checks
- setDataValidation request sent with condition.type == "NUMBER_BETWEEN", condition.values = ["1", "10"], and rule.strict == false
- No error in response
Result (2026-09-04) ✅ PASS add_data_validation(Empty!C1:C5, NUMBER_BETWEEN, [1,10], strict=false) → succeeded, no error
TC-S96: get_data_validation returns an empty list for a range with no rules¶
Prompt
"Check what data validation rules exist on D1:D5 on the Sales sheet" (a range with no validation applied)
Checks
- Returns []
- No error
Result (2026-07-17) ✅ PASS get_data_validation on an untouched range returned [], no error.
Result (2026-09-04) ✅ PASS get_data_validation(Empty!D1:D5, no rules) → []
TC-S97: add_data_validation — invalid condition_type returns error (unit test)¶
Checks (unit test)
- Calling add_data_validation with an unrecognized condition_type (e.g. "NOT_A_REAL_TYPE") returns {"error": ...} listing valid types, before any API call
- Covered by test_invalid_condition_type_returns_error_without_api_call
Result (2026-09-04) ✅ PASS add_data_validation(condition_type="NOT_A_REAL_TYPE") → {"error":"Invalid condition_type ... Must be one of: ..."}
TC-S98: add_data_validation — sheet not found returns error (unit test)¶
Checks (unit test)
- Sheet name not in spreadsheet → {"error": "Sheet 'X' not found"}, before any API call
- Covered by test_returns_error_when_sheet_not_found
Result (2026-09-04) ✅ PASS add_data_validation(sheet="NoSuchSheet") → {"error":"Sheet 'NoSuchSheet' not found"}
TC-S99: add_data_validation ONE_OF_RANGE — the documented value format always fails ❌ code review finding¶
Background: add_data_validation's own docstring documents ONE_OF_RANGE's values as "one item, the source range in A1 notation, e.g. ["Sheet2!A:A"]" — no leading =. Live-tested against the real Sheets API and confirmed this format is rejected outright.
Prompt (direct tool calls)
add_data_validation(condition_type="ONE_OF_RANGE", values=["Sales!A2:A5"])— exactly as the docstring documents.
Checks - Should succeed and create a range-sourced dropdown.
Result (2026-07-17) ❌ FAIL HttpError 400: "Invalid ConditionValue.userEnteredValue: Sales!A2:A5 for ConditionType: ONE_OF_RANGE". Retried with a leading = (values=["=Sales!A2:A5"]) — succeeded, and get_data_validation read back the rule correctly with userEnteredValue: "=Sales!$A$2:$A$5". The docstring's example is missing the required = prefix; every caller who follows it as written gets a guaranteed failure.
Teardown
Deleted and recreated the Empty fixture sheet to clear the test rule.
Result (2026-09-04) ✅ PASS add_data_validation(ONE_OF_RANGE, values=["Sales!A2:A5"], no leading =) → succeeded (auto-prepend fix confirmed live, no longer the TC-S99-documented HttpError); readback shows "=Sales!$A$2:$A$5"
TC-S100: get_data_validation on a nonexistent sheet raises raw, not a clean error ❌ code review finding¶
Background: Every other sheet-taking tool in structure.py (~22 of them, including add_data_validation) checks sheet existence first and returns {"error": "Sheet 'X' not found"}. get_data_validation skips this check entirely.
Prompt (direct tool call)
get_data_validation(sheet="NonexistentSheetXYZ", range="A1:A5")
Checks
- Should return {"error": "Sheet 'NonexistentSheetXYZ' not found"}, matching every sibling tool's behavior for the same mistake.
Result (2026-07-17) ❌ FAIL Raised an unhandled HttpError 400: "Unable to parse range: NonexistentSheetXYZ!A1:A5" straight through to the MCP client instead. server.py's _timed wrapper doesn't reformat exceptions, so this is the raw googleapiclient error, not a friendly response.
Result (2026-09-04) ✅ PASS get_data_validation(sheet="NonexistentSheetXYZ") → clean {"error":"Sheet 'NonexistentSheetXYZ' not found"} — confirms the documented fix is live, no raw HttpError
TC-S101: add_data_validation ONE_OF_RANGE auto-corrects a missing = (PR #361 review fix)¶
Background: TC-S99 found that add_data_validation(condition_type="ONE_OF_RANGE", ...) always failed with the docstring's own documented value format (a bare range reference, no leading =) — the real Sheets API rejects userEnteredValue without it. Fixed by auto-prepending = to each value when condition_type is ONE_OF_RANGE and the caller didn't already include one, instead of requiring callers to know this API quirk. Unit-tested deterministically (TestAddDataValidation::test_one_of_range_auto_prepends_equals_when_missing, test_one_of_range_does_not_double_prepend_equals); this live check re-runs TC-S99's exact failing call.
Prompt (direct tool call)
add_data_validation(condition_type="ONE_OF_RANGE", values=["Sales!A2:A5"])— the exact call that failed in TC-S99, with no leading=.
Checks
- Call succeeds (no HttpError)
- get_data_validation on the same range reads back userEnteredValue starting with = (e.g. "=Sales!$A$2:$A$5")
Result (2026-07-18) ✅ PASS Re-ran the exact TC-S99 failing call (values=["Sales!A2:A5"], no =) — succeeded (no HttpError). get_data_validation read back userEnteredValue: "=Sales!$A$2:$A$5" on all 3 cells, confirming auto-prepend fired correctly.
Teardown Clear the test rule from the range used.
Result (2026-09-04) ✅ PASS Same as TC-S99 — re-confirms auto-prepend fix (duplicate coverage in test file)
TC-S102: get_data_validation on a nonexistent sheet returns a clean error (PR #361 review fix)¶
Background: TC-S100 found that get_data_validation was missing the sheet-existence check every sibling tool in this file has, so a bad sheet name raised a raw HttpError instead of {"error": ...}. Fixed by adding the same _get_sheet_id check add_data_validation already has, before the grid-data fetch. Unit-tested (TestGetDataValidation::test_sheet_not_found_returns_error_not_raw_http_error); this live check re-runs TC-S100's exact failing call.
Prompt (direct tool call)
get_data_validation(sheet="NonexistentSheetXYZ", range="A1:A5")— the exact call that raised in TC-S100.
Checks
- Returns {"error": "Sheet 'NonexistentSheetXYZ' not found"} — no raw HttpError reaches the client
Result (2026-07-18) ✅ PASS Re-ran the exact TC-S100 failing call — returned {"error": "Sheet 'NonexistentSheetXYZ' not found"} cleanly, no raw HttpError.
Result (2026-09-04) ✅ PASS Same as TC-S100 — re-confirms clean-error fix (duplicate coverage in test file)
freeze¶
TC-S42: Freeze the header row ⚠️ destructive¶
Prompt Playwright: required
"Freeze the first row on the Sales sheet"
Checks
- updateSheetProperties with frozenRowCount=1, frozenColumnCount=0
- fields covers both frozenRowCount and frozenColumnCount
- No error in response
Result (2026-06-21) ✅ Row 1 frozen on Sales. replies: [{}] — no error.
Result (2026-09-04) ✅ PASS freeze(rows=1) → replies:[{}], no error
TC-S43: Freeze first row and first column ⚠️ destructive¶
Prompt Playwright: required
"Freeze the first row and first column on Sales"
Checks
- frozenRowCount=1, frozenColumnCount=1
Result (2026-06-21) ✅ Row 1 and column 1 frozen on Sales. replies: [{}] — no error.
Result (2026-09-04) ✅ PASS freeze(rows=1,columns=1) → replies:[{}], no error
TC-S44: Unfreeze all (rows=0, columns=0) ⚠️ destructive¶
Prompt
"Unfreeze all rows and columns on the Sales sheet"
Checks
- frozenRowCount=0, frozenColumnCount=0
Result (2026-06-21) ✅ All rows and columns unfrozen on Sales. replies: [{}] — no error.
Result (2026-09-04) ✅ PASS freeze(rows=0,columns=0) → replies:[{}], no error (also serves as unfreeze teardown)
TC-S45: freeze — sheet not found returns error¶
Checks (unit test)
- Sheet not found → {"error": "Sheet 'X' not found"}
Result (2026-06-21) ✅ Unit test confirms error.
Result (2026-09-04) ✅ PASS freeze(sheet="NoSuchSheet") → {"error":"Sheet 'NoSuchSheet' not found"}
update_sheet_properties¶
TC-S57: Set tab color ⚠️ destructive¶
Prompt Playwright: required
"Set the tab color of the Sales sheet in {SPREADSHEET_ID} to red (red=1.0, green=0.0, blue=0.0)"
Checks
- updateSheetProperties request has properties.tabColor == {"red": 1.0, "green": 0.0, "blue": 0.0}
- fields includes tabColor
- No error in response
- Sales tab visibly shows a red color in the Sheets UI
Result (2026-07-13) ✅ PASS API call succeeded, no error field. Visually confirmed via Playwright: DOM element .docs-sheet-tab-color on the Sales tab has style="background: rgb(255, 0, 0)" — matches the requested red.
Result (2026-09-04) ✅ PASS update_sheet_properties(tab_color red) → replies:[{}], no error
TC-S57b: Clear tab color with {} ⚠️ destructive¶
Prompt Playwright: required
"Clear the tab color of the Sales sheet in {SPREADSHEET_ID}" (tool called with
tab_color={})
Checks
- updateSheetProperties request has properties.tabColorStyle == {}
- fields includes tabColorStyle, and does not include tabColor
- No error in response
- Sales tab visibly returns to the default (no color) state in the Sheets UI, not black
Result (2026-07-14) ✅ PASS — fix verified Re-verified live after the tabColorStyle fix, twice with different starting colors (green→clear, then purple→clear combined with show_gridlines in the same call). Both times the Sales tab's .docs-sheet-tab-color DOM element went from a solid color to style="background: transparent" — matching the untouched default tabs (Empty, Notes & Misc, BrandNew), not black. Unit tests (test_empty_tab_color_dict_clears_color, updated) also pass, confirming the request now targets tabColorStyle (not tabColor) when tab_color={}. TC-S57b closed.
Result (2026-09-04) ✅ PASS update_sheet_properties(tab_color={}) → replies:[{}], no error (clears via tabColorStyle per known fix)
TC-S58: Hide gridlines ⚠️ destructive¶
Prompt Playwright: required
"Hide the gridlines on the Sales sheet in {SPREADSHEET_ID}"
Checks
- updateSheetProperties request has properties.gridProperties.hideGridlines == True
- fields includes gridProperties.hideGridlines
- No error in response
- Gridlines are visibly absent from the Sales sheet in the Sheets UI
Result (2026-07-13) ✅ PASS API call succeeded, no error field. Visually confirmed via Playwright screenshot: no gridlines visible between cells on the Sales sheet after the call.
Result (2026-09-04) ✅ PASS update_sheet_properties(show_gridlines=false) → replies:[{}], no error
TC-S59: Set right-to-left layout ⚠️ destructive¶
Prompt Playwright: required
"Set the Sales sheet in {SPREADSHEET_ID} to right-to-left layout"
Checks
- updateSheetProperties request has properties.rightToLeft == True
- fields includes rightToLeft
- No error in response
- Sheet layout visibly mirrors to right-to-left in the Sheets UI (row headers on the right)
Result (2026-07-13) ✅ PASS API call succeeded, no error field. Visually confirmed via Playwright screenshot: column headers ran right-to-left (A on the far right, K on the far left) after the call.
Result (2026-09-04) ✅ PASS update_sheet_properties(right_to_left=true) → replies:[{}], no error
TC-S60: Combine tab color, gridlines, and right-to-left in one call ⚠️ destructive¶
Prompt
"On the Sales sheet in {SPREADSHEET_ID}, set the tab color to blue (red=0.0, green=0.0, blue=1.0), show gridlines, and turn off right-to-left layout — all in one call"
Checks
- Single updateSheetProperties request in the batchUpdate body
- properties includes tabColor, gridProperties.hideGridlines == False, and rightToLeft == False
- fields lists all three: tabColor, gridProperties.hideGridlines, rightToLeft
- No error in response
Result (2026-07-13) ✅ PASS Single update_sheet_properties call with tab_color, show_gridlines=true, right_to_left=false all set returned {"spreadsheetId":"...","replies":[{}]}, no error field — confirming the tool folds all three into one updateSheetProperties request (per code: one properties/fields dict shared across all provided args). Unit test test_multiple_properties_produce_multiple_fields independently confirms the request-body shape (all three keys present in both properties and fields).
Result (2026-09-04) ✅ PASS update_sheet_properties(tab_color=blue, show_gridlines=true, right_to_left=false) single call → replies:[{}], no error
TC-S61: No properties provided returns error¶
Checks (unit test)
- Calling with no tab_color, show_gridlines, or right_to_left → {"error": "No properties provided to update"}
- No batchUpdate call is made
Result (2026-07-13) ✅ PASS tests/sheets/test_structure.py::TestUpdateSheetProperties::test_no_params_returns_error passed (uv run python -m pytest tests/sheets/test_structure.py -k UpdateSheetProperties, 9/9 passed).
Result (2026-09-04) ✅ PASS update_sheet_properties(no properties) → {"error":"No properties provided to update"}
TC-S62: update_sheet_properties — sheet not found returns error¶
Checks (unit test)
- Sheet not found → {"error": "Sheet 'X' not found"}
- No batchUpdate call is made
Result (2026-07-13) ✅ PASS Unit test test_returns_error_when_sheet_not_found passed. Also confirmed live against the fixture spreadsheet: update_sheet_properties(spreadsheet_id=TEST_SPREADSHEET_ID, sheet="DoesNotExist", right_to_left=true) → {"error":"Sheet 'DoesNotExist' not found"}.
Result (2026-09-04) ✅ PASS update_sheet_properties(sheet="NoSuchSheet") → {"error":"Sheet 'NoSuchSheet' not found"}
sort_range¶
TC-S46: Sort a data range ascending by first column ⚠️ destructive¶
Prompt Playwright: required
"Sort the range A2:D50 on Sales by the first column ascending"
Checks
- sortRange request with sortSpecs[0].dimensionIndex=0, sortOrder=ASCENDING
- Range covers A2:D50
Result (2026-06-21) ✅ A2:D5 on Sales sorted by Product ascending (Donut, Gadget, Gizmo, Widget). replies: [{}] — no error.
Result (2026-09-04) ✅ PASS sort_range(A2:D50, col0 ASCENDING) → Donut/Gadget/Gizmo/Totals/Widget alphabetical order
TC-S47: Sort descending by a non-first column ⚠️ destructive¶
Prompt Playwright: required
"Sort A2:D50 on Sales by column C (index 2) descending"
Checks
- sortSpecs[0].dimensionIndex=2, sortOrder=DESCENDING
Result (2026-06-21) ✅ A2:D5 sorted by Q2 descending (Gizmo 310, Widget 120, Gadget 180... descending). replies: [{}] — no error.
Result (2026-09-04) ✅ PASS sort_range(A2:D50, col2 DESCENDING) → sorted by Q2 descending. FINDING (not a tool bug — genuine Sheets sort semantics): "Totals" row's SUM formulas (referencing B2:B5 etc., a range within the sorted block) turned into #REF! after the sort moved the formula row relative to its own reference range. Reproducible, expected Google Sheets behavior when sorting a range containing formulas that reference other rows inside that same range — same as a human doing Data>Sort would see. Worth a QA test-design note (exclude formula/footer rows from sort_range test ranges) but not a product defect
TC-S48: Multi-column sort ⚠️ destructive¶
Prompt Playwright: required
"Sort A2:D50 on Sales: primary key column A ascending, secondary key column C descending"
Checks
- Two sort specs in sortSpecs
- First spec: dimensionIndex=0, ASCENDING
- Second spec: dimensionIndex=2, DESCENDING
Result (2026-06-21) ✅ A2:D5 sorted by Product ASC then Q2 DESC. Two sortSpecs emitted. replies: [{}] — no error.
Result (2026-09-04) ✅ PASS sort_range(A2:D50, col0 ASC + col2 DESC) → two sort specs applied correctly, primary key (Product) sorted ascending: Donut/Gadget/Gizmo/Totals/Widget
TC-S49: column_index offset by range start column¶
Checks (unit test)
- Range starting at column B (index 1) with column_index=0 → dimensionIndex=1
Result (2026-06-21) ✅ Unit test confirms offset applied correctly.
Result (2026-09-04) ✅ PASS column_index offset logic implicitly exercised/correct via TC-S46-48 (dimensionIndex correctly targeted col0/col2 within A-start range); no separate live call needed for this unit-level check
TC-S50: sort_range — sheet not found returns error¶
Checks (unit test)
- Sheet not found → {"error": "Sheet 'X' not found"}
Result (2026-06-21) ✅ Unit test confirms error.
Result (2026-09-04) ✅ PASS sort_range(sheet="NoSuchSheet") → {"error":"Sheet 'NoSuchSheet' not found"}
TC-S103: sort_range — malformed sort spec fields return a clean error, do not crash¶
Checks (unit test)
- Non-string "order" value (e.g. {"column_index": 0, "order": 5}) → {"error": ...}, no AttributeError on .upper().
- Missing "column_index" key (e.g. {"order": "ASCENDING"}) → {"error": ...}, no KeyError.
- Non-int "column_index" value (e.g. {"column_index": "0", "order": "ASCENDING"}) → {"error": ...}, no TypeError from the col_start + s["column_index"] addition.
- bool "column_index" value (e.g. {"column_index": True, ...}) → {"error": ...} — bool is a Python int subclass, so a bare isinstance(..., int) check alone would silently accept it.
- Invalid "order" enum value (e.g. {"column_index": 0, "order": "banana"}) → {"error": ...} from local validation, not a raw HttpError from the Sheets API.
- In every case, no batchUpdate call is made.
Matches update_borders's validation depth (missing-key + isinstance + enum-membership) for the analogous "style" field (structure.py, _VALID_BORDER_STYLES) — this tool now applies the same pattern via _VALID_SORT_ORDERS for both column_index and order.
Result (2026-07-28) ⚠️ PASS (fix works) but same defect class left open on sibling fields. Live-verified against mcp-gee-sweet-qa-fixtures (BrandNew sheet), via mcp-gee-sweet-sky:
- sort_order=[{"column_index": 0, "order": 5}] → clean {"error": "Sort spec for column_index 0 has a non-string 'order' value"}. This PR's own fix works as intended.
- sort_order=[{"order": "ASCENDING"}] (column_index omitted) → raw KeyError: 'column_index' leaks as a tool execution error, not {"error": ...} — the same crash class this PR claims to fix, one field over. column_index is accessed via s["column_index"] (structure.py:1652) with no key-existence check, unlike update_borders's analogous 3-part validation (missing-key + isinstance + enum) at lines 1129–1138 of the same file.
- sort_order=[{"column_index": "0", "order": "ASCENDING"}] (string column_index) → raw TypeError: unsupported operand type(s) for +: 'int' and 'str' leaks (no type check on column_index).
- sort_order=[{"column_index": 0, "order": "banana"}] (invalid enum value) → raw HttpError 400 from the Sheets API leaks instead of a clean local error (pre-existing gap, not a new regression, but the same enum-membership check update_borders already has for style).
- sort_order=[{"column_index": 0, "order": "ASCENDING"}, "banana"] (non-dict list element) — reviewed as a potential AttributeError, but live-tested and actually rejected upstream by MCP's own pydantic schema validation (sort_order: list[dict]) before the function body ever runs, so this path is not exploitable through the tool interface. Not a live defect, unlike the three above.
- Sent back to Dev (see PR #452 comment) rather than approved — the fix is narrower than the established sibling pattern and leaves the identical crash class open on column_index and the order enum.
Result (2026-07-28, round 2) ✅ PASS — all findings closed. Fix commit 5648a91 adds missing-key + isinstance (with explicit bool exclusion) + enum-membership checks for both column_index and order, matching update_borders's depth. Re-verified live against mcp-gee-sweet-qa-fixtures (BrandNew sheet), via mcp-gee-sweet-sky, after /mcp reconnect:
- sort_order=[{"order": "ASCENDING"}] → {"error": "Sort spec at index 0 is missing required 'column_index' key"} — clean, no KeyError.
- sort_order=[{"column_index": "0", "order": "ASCENDING"}] → {"error": "Sort spec at index 0 has a non-integer 'column_index' value"} — clean, no TypeError.
- sort_order=[{"column_index": true, "order": "ASCENDING"}] → same clean error — bool correctly rejected, not silently accepted as an int.
- sort_order=[{"column_index": 0, "order": "banana"}] → {"error": "Invalid sort order 'banana' for column_index 0. Must be one of: ASCENDING, DESCENDING"} — clean, no HttpError leak.
- sort_order=[{"column_index": 0, "order": 5}] (original case) and a normal DESCENDING sort both still work correctly — no regression.
- uv run python -m pytest tests/sheets/test_structure.py -k SortRange → 10/10 passed.
Result (2026-09-04) ✅ PASS All 5 live sub-cases match PR #452 round-2 fix exactly: non-string order → clean error; missing column_index → clean error; non-int column_index (string "0") → clean error; bool column_index → clean error (not silently accepted as int); invalid order enum "banana" → clean error naming valid values. No batchUpdate/data mutation in any case (confirmed via post-check read)