Skip to content

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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.

Result (2026-10-19) ✅ PASS — v0.9.3 release pass kai-sa: list_sheets returned Sales / Notes & Misc / Empty (3 tabs), no error.

Result (2026-10-20) ✅ PASS — v0.9.3 post-advisory re-check @ c90b88d3 kai-sa (service account) and kai-oauth (OAuth): Sales / Notes & Misc / Empty, 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)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.

Result (2026-10-20) ✅ PASS — v0.9.3 post-advisory re-check @ c90b88d3 kai-sa (service account) and kai-oauth (OAuth): both calls logged Cache hit: N sheets for <copy>.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.

Result (2026-10-20) ✅ PASS — v0.9.3 post-advisory re-check @ c90b88d3 kai-sa (service account) and kai-oauth (OAuth): rename_sheet(Empty→WasEmpty) logged Marked cache dirty, and the next list_sheets re-fetched and showed WasEmpty. On kai-oauth (OAuth) that list_sheets took 9.8 s (every other one under 1 s); the log shows no cause, not reproduced.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.

Result (2026-10-19) ✅ PASS — v0.9.3 release pass kai-sa: create_sheet("BrandNew") returned sheetId 917700788, title, index 3 and spreadsheetId. The next list_sheets included BrandNew.

Result (2026-10-20) ✅ PASS — v0.9.3 post-advisory re-check @ c90b88d3 kai-sa (service account) and kai-oauth (OAuth): create_sheet("BrandNew") returned sheetId/title/index 3; Marked cache dirty, then the next list_sheets re-fetched 4 sheets.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


add_rows / add_columns

TC-S104: Non-positive count returns error (unit test)

Checks (unit test) - add_rows(spreadsheet_id, sheet, count=0) → {"error": "count must be positive, got 0"}, no batchUpdate call - add_rows(spreadsheet_id, sheet, count=-2) → {"error": "count must be positive, got -2"}, no batchUpdate call - add_columns(spreadsheet_id, sheet, count=0) → {"error": "count must be positive, got 0"}, no batchUpdate call - add_columns(spreadsheet_id, sheet, count=-1) → {"error": "count must be positive, got -1"}, no batchUpdate call - Previously a non-positive count passed straight to insertDimension (startIndex == endIndex or startIndex > endIndex), which the Sheets API rejected with an opaque 400 rather than a clear local error (issue #323) - Covered by test_zero_count_returns_error_without_api_call / test_negative_count_returns_error_without_api_call in TestAddRows/TestAddColumns

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S112: add_rows / add_columns — negative start returns error (unit test)

Checks (unit test) - add_rows(spreadsheet_id, sheet, count=1, start_row=-1) → {"error": "start_row must be non-negative, got -1"}, no batchUpdate call - add_columns(spreadsheet_id, sheet, count=1, start_column=-1) → {"error": "start_column must be non-negative, got -1"}, no batchUpdate call - Previously a negative start_row/start_column passed straight to insertDimension with a negative startIndex, which the Sheets API rejected with an opaque 400 rather than a clear local error (PR #734 QA round 1, issue #323) - Covered by test_negative_start_row_returns_error_without_api_call / test_negative_start_column_returns_error_without_api_call in TestAddRows/TestAddColumns

Result (2026-09-12) ✅ — unit tests pass (155/155). Live-verified via mcp-gee-sweet-kit against the QA fixture spreadsheet (Empty sheet): add_rows(start_row=-1, count=1) and add_columns(start_column=-1, count=1) both returned the exact documented error with no batchUpdate call.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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:"]}, scoped correctly

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.

Result (2026-10-19) ⚠️ PARTIAL — v0.9.3 release pass kai-sa: refresh_cache(spreadsheet_id) returned {"invalidated":["spreadsheet:<id>"]}, and a following list_sheets returned the current tabs. Not run: the "next call is a cache miss in the logs" check, because the rules forbid reading logs.

Result (2026-10-20) ✅ PASS — v0.9.3 post-advisory re-check @ c90b88d3 kai-sa (service account) and kai-oauth (OAuth): refresh_cache(spreadsheet_id) → invalidated: [spreadsheet:<copy>], both caches marked dirty, next list_sheets a miss (Cached 4 sheets).


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)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.

Result (2026-10-20) ✅ PASS — v0.9.3 post-advisory re-check @ c90b88d3 kai-sa (service account): refresh_cache() → invalidated: all; the log names all five caches (sheet_structure, sheet_data, drive_folder, doc_content, calendar), and the next list_sheets, get_doc_content and list_files each re-fetched. Stale wording: five caches now, not four.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.

Result (2026-10-20) ✅ PASS — v0.9.3 post-advisory re-check @ c90b88d3 kai-sa (service account): after refresh_cache(spreadsheet_id) a summary re-fetched (Cached 4 sheets, then Cached sheet data … per tab) and returned current data.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S105: end_row before start_row returns error (unit test)

Checks (unit test) - delete_rows(spreadsheet_id, sheet, start_row=5, end_row=2) → {"error": "end_row (2) must be >= start_row (5)"}, no batchUpdate call - Previously an inverted range passed straight through to deleteDimension's computed (invalid) startIndex/endIndex pair, which the Sheets API rejected with an opaque 400 rather than a clear local error (issue #323) - Covered by test_end_row_before_start_row_returns_error_without_api_call in TestDeleteRows

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S113: delete_rows — negative start_row returns error (unit test)

Checks (unit test) - delete_rows(spreadsheet_id, sheet, start_row=-1) → {"error": "start_row must be non-negative, got -1"}, no batchUpdate call - Previously a negative start_row passed straight through to deleteDimension with a negative startIndex (PR #734 QA round 1, issue #323) - Covered by test_negative_start_row_returns_error_without_api_call in TestDeleteRows

Result (2026-09-12) ✅ — unit tests pass (155/155). Live-verified via mcp-gee-sweet-kit against the QA fixture spreadsheet (Empty sheet): delete_rows(start_row=-1) returned the exact documented error with no batchUpdate call.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S106: end_column before start_column returns error (unit test)

Checks (unit test) - delete_columns(spreadsheet_id, sheet, start_column=5, end_column=2) → {"error": "end_column (2) must be >= start_column (5)"}, no batchUpdate call - Same underlying gap as TC-S105, one dimension over (issue #323) - Covered by test_end_column_before_start_column_returns_error_without_api_call in TestDeleteColumns

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S114: delete_columns — negative start_column returns error (unit test)

Checks (unit test) - delete_columns(spreadsheet_id, sheet, start_column=-1) → {"error": "start_column must be non-negative, got -1"}, no batchUpdate call - Same underlying gap as TC-S113, one dimension over (PR #734 QA round 1, issue #323) - Covered by test_negative_start_column_returns_error_without_api_call in TestDeleteColumns

Result (2026-09-12) ✅ — unit tests pass (155/155). Live-verified via mcp-gee-sweet-kit against the QA fixture spreadsheet (Empty sheet): delete_columns(start_column=-1) returned the exact documented error with no batchUpdate call.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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) ⚠️ PARTIAL hide_rows(start_row=4) → rowMetadata[4].hiddenByUser=true (API-verified, no Playwright per shard instructions) Corrected (2026-10-17, #1044 audit): the visual check wasn't run (API-only per shard instructions).

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S107: hide_rows / unhide_rows — end_row before start_row returns error (unit test)

Checks (unit test) - hide_rows(spreadsheet_id, sheet, start_row=5, end_row=2) → {"error": "end_row (2) must be >= start_row (5)"}, no batchUpdate call - unhide_rows(spreadsheet_id, sheet, start_row=5, end_row=2) → same error shape, no batchUpdate call - Both tools share the same underlying _range_order_error check (issue #323) - Covered by test_end_row_before_start_row_returns_error_without_api_call in TestHideRows/TestUnhideRows

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S115: hide_rows / unhide_rows — negative start_row returns error (unit test)

Checks (unit test) - hide_rows(spreadsheet_id, sheet, start_row=-1) → {"error": "start_row must be non-negative, got -1"}, no batchUpdate call - unhide_rows(spreadsheet_id, sheet, start_row=-1) → same error shape, no batchUpdate call - Both tools share the same underlying _non_negative_value_error check (PR #734 QA round 1, issue #323) - Covered by test_negative_start_row_returns_error_without_api_call in TestHideRows/TestUnhideRows

Result (2026-09-12) ✅ — unit tests pass (155/155). Live-verified via mcp-gee-sweet-kit against the QA fixture spreadsheet (Empty sheet): hide_rows(start_row=-1) and unhide_rows(start_row=-1) both returned the exact documented error with no batchUpdate call.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S108: hide_columns / unhide_columns — end_column before start_column returns error (unit test)

Checks (unit test) - hide_columns(spreadsheet_id, sheet, start_column=5, end_column=2) → {"error": "end_column (2) must be >= start_column (5)"}, no batchUpdate call - unhide_columns(spreadsheet_id, sheet, start_column=5, end_column=2) → same error shape, no batchUpdate call - Both tools share the same underlying _range_order_error check (issue #323) - Covered by test_end_column_before_start_column_returns_error_without_api_call in TestHideColumns/TestUnhideColumns

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S116: hide_columns / unhide_columns — negative start_column returns error (unit test)

Checks (unit test) - hide_columns(spreadsheet_id, sheet, start_column=-1) → {"error": "start_column must be non-negative, got -1"}, no batchUpdate call - unhide_columns(spreadsheet_id, sheet, start_column=-1) → same error shape, no batchUpdate call - Both tools share the same underlying _non_negative_value_error check (PR #734 QA round 1, issue #323) - Covered by test_negative_start_column_returns_error_without_api_call in TestHideColumns/TestUnhideColumns

Result (2026-09-12) ✅ — unit tests pass (155/155). Live-verified via mcp-gee-sweet-kit against the QA fixture spreadsheet (Empty sheet): hide_columns(start_column=-1) and unhide_columns(start_column=-1) both returned the exact documented error with no batchUpdate call.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S109: resize_rows / resize_columns — non-positive pixel_size returns error (unit test)

Checks (unit test) - resize_rows(spreadsheet_id, sheet, start_row=0, pixel_size=0) → {"error": "pixel_size must be positive, got 0"}, no batchUpdate call - resize_columns(spreadsheet_id, sheet, start_column=0, pixel_size=-5) → {"error": "pixel_size must be positive, got -5"}, no batchUpdate call - Previously a non-positive pixel_size passed straight to updateDimensionProperties, which the Sheets API either silently clamped or rejected with an opaque 400 rather than a clear local error (issue #323) - Covered by test_non_positive_pixel_size_returns_error_without_api_call in TestResizeRows/TestResizeColumns

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S110: resize_rows / resize_columns — end index before start index returns error (unit test)

Checks (unit test) - resize_rows(spreadsheet_id, sheet, start_row=5, end_row=2, pixel_size=50) → {"error": "end_row (2) must be >= start_row (5)"}, no batchUpdate call - resize_columns(spreadsheet_id, sheet, start_column=5, end_column=2, pixel_size=80) → {"error": "end_column (2) must be >= start_column (5)"}, no batchUpdate call - Covered by test_end_row_before_start_row_returns_error_without_api_call / test_end_column_before_start_column_returns_error_without_api_call in TestResizeRows/TestResizeColumns

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S111: resize_rows / resize_columns — sheet lookup now precedes pixel_size/auto_resize validation (unit test)

Checks (unit test) - resize_rows(spreadsheet_id, sheet="NoSuchSheet", start_row=0) (no pixel_size/auto_resize given) → {"error": "Sheet 'NoSuchSheet' not found"}, not {"error": "Specify pixel_size or set auto_resize=True"} - resize_columns(spreadsheet_id, sheet="NoSuchSheet", start_column=0) → same shape - Previously the mutual-exclusivity check ran before the sheet lookup, so a bad sheet name paired with missing/conflicting resize params returned the params error first, masking the more fundamental problem — now matches format_cells' sheet-first ordering (issue #323) - Covered by test_bad_sheet_name_reported_before_missing_params in TestResizeRows/TestResizeColumns

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S117: resize_rows / resize_columns — negative start returns error (unit test)

Checks (unit test) - resize_rows(spreadsheet_id, sheet, start_row=-1, pixel_size=50) → {"error": "start_row must be non-negative, got -1"}, no batchUpdate call - resize_columns(spreadsheet_id, sheet, start_column=-1, pixel_size=80) → {"error": "start_column must be non-negative, got -1"}, no batchUpdate call - Same underlying gap as TC-S112/S113/S115, extended to resize (PR #734 QA round 1, issue #323) - Covered by test_negative_start_row_returns_error_without_api_call / test_negative_start_column_returns_error_without_api_call in TestResizeRows/TestResizeColumns

Result (2026-09-12) ✅ — unit tests pass (155/155). Live-verified via mcp-gee-sweet-kit against the QA fixture spreadsheet (Empty sheet): resize_rows(start_row=-1, pixel_size=50) and resize_columns(start_column=-1, pixel_size=80) both returned the exact documented error with no batchUpdate call.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S123: format_cells — invalid A1 notation range returns a clean error (issue #747, unit test)

Checks (unit test) - format_cells(spreadsheet_id, sheet="Sheet1", range="Sheet1!A1", bold=True) → {"error": "Invalid A1 notation: Sheet1!A1"}, not an uncaught ValueError - No batchUpdate API call is made - Covered by test_invalid_range_returns_error_without_api_call in TestFormatCells

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Error text now repr-quotes the range; TC wording stale.


TC-S131: format_cells — unbounded or trailing-newline range is rejected without formatting anything (PR #922) ⚠️ destructive

Background: _parse_a1_notation used to accept a bare : (every group optional, so the regex matched with no bounds) and any range with a trailing newline ($ matches before a final \n). For format_cells, range=":" produced a GridRange with only a sheetId, so repeatCell formatted the entire sheet. PR #922 switched to re.fullmatch and requires at least one row or column bound. Partial-bound forms (A1:, A:, :B5, 1:B) are tracked separately in #926.

Destructive because a regressed build formats the whole sheet. Never run it against a shared fixture; use the scratch spreadsheet from Setup.

Setup: create_spreadsheet(title="qa-tc-s131-scratch"), then update_cells(spreadsheet_id=<new id>, sheet="Sheet1", range="A1:E5", data=<5×5 of any text values>). Keep the ID; TC-S132 reuses this spreadsheet.

Prompt

"Read get_sheet_data(spreadsheet_id=<scratch id>, sheet=\"Sheet1\", range=\"A1:F8\", include_grid_data=True). Then call format_cells(spreadsheet_id=<scratch id>, sheet=\"Sheet1\", range=\":\", bold=True), then format_cells(..., range=\"A1\\n\", bold=True) (a real trailing newline). Then read A1:F8 the same way again."

Checks - range=":" → {"error": "Invalid A1 notation: :"} - range="A1\n" → {"error": "Invalid A1 notation: A1\n"} - The before and after A1:F8 reads are identical, with no bold cell (no batchUpdate reached the API)

Teardown: none if TC-S132 runs next (it deletes the spreadsheet); otherwise delete_file(file_id=<scratch id>).

Result (2026-10-06) ✅ PASS Scratch spreadsheet with values in A1:E5, no formatting. Both calls returned the expected error. A grid-data read of A1:E5 (include_grid_data=True) taken before and after the two calls was byte-identical, with no bold cells. format_cells resolves the sheet ID before it parses the range, so a nonexistent spreadsheet ID would 404 at the sheet lookup for valid and invalid ranges alike; the unchanged grid is the live evidence that no write was sent. Tested in an isolated service-account sandbox on a Shared Drive.

Result (2026-10-08, PR #929 QA re-run) ✅ PASS Fresh scratch spreadsheet (create_spreadsheet, then update_cells A1:E5 with a1…e5), via mcp-gee-sweet-kit running code that includes #922 (6dd5e6a). range=":" → {"error":"Invalid A1 notation: :"}; range="A1\n" → {"error":"Invalid A1 notation: A1\n"}. No format_cells call preceded these two, and update_cells writes values only, so the new sheet started with no bold; a grid-data read of A1:F8 (include_grid_data=True) afterward showed no bold cells. No separate "before" read was taken, so this rests on the fresh sheet's default formatting rather than a byte-identical before/after comparison.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S132: format_cells — bounded and open-ended ranges still format exactly their target (PR #922 regression) ⚠️ destructive

Setup: TC-S131's scratch spreadsheet (values in A1:E5, no formatting). If TC-S131 didn't run, create one the same way. Data stops at row 5 and column E, so the A1:F8 read window shows whether an open-ended range extends past the data.

Prompt

"For each of A1, A1:B2, A:A, 1:2, B2:D in turn: call format_cells(spreadsheet_id=<scratch id>, sheet=\"Sheet1\", range=<range>, bold=True), read get_sheet_data(..., range=\"A1:F8\", include_grid_data=True) and list the bold cells, then reset with format_cells(..., range=\"A:Z\", bold=False) (all 26 columns of a new sheet, so 1:2's bold past column F is cleared too)."

Checks - A1 → only A1 is bold - A1:B2 → A1, B1, A2, B2 only - A:A → all of column A, including rows past the data - 1:2 → all of rows 1–2, including columns past the data - B2:D → columns B–D from row 2 down, including rows past the data - No cell outside the target is bold, and every call returns replies: [{}] with no error

Teardown: delete_file(file_id=<scratch id>).

Result (2026-10-06) ✅ PASS Values in A1:E5, so a read window wider than the data shows whether open-ended forms extend past it. Read A1:E5 for A1 and A1:B2, and A1:F8 for the rest. Bold cells after each call: A1 → [A1]; A1:B2 → [A1, B1, A2, B2]; A:A → [A1–A8]; 1:2 → [A1–F1, A2–F2]; B2:D → [B2–D8]. All calls returned replies: [{}]. Tested in an isolated service-account sandbox on a Shared Drive.

Result (2026-10-08, PR #929 QA re-run) ✅ PASS Same scratch spreadsheet as TC-S131's re-run (values in A1:E5), read A1:F8 after every call, reset with format_cells(range="A:F", bold=False) between ranges. Bold cells: A1 → [A1]; A1:B2 → [A1, B1, A2, B2]; A:A → [A1–A8]; 1:2 → [A1–F1, A2–F2]; B2:D → [B2–D8]. Every call returned replies: [{}]. The A:F reset doesn't clear the bold 1:2 applied from column G onward, and the A1:F8 window can't see past column F, so "no cell outside the target is bold" was confirmed only within A1:F8 (the 2026-10-06 run used the same window). Scratch spreadsheet trashed after both test cases.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run on the Empty sheet to avoid destroying Sales data; TC prompt says 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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run on the Empty sheet to avoid destroying Sales data; TC prompt says Sales.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run on the Empty sheet to avoid destroying Sales data; TC prompt says Sales.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S124: merge_cells / unmerge_cells — invalid A1 notation range returns a clean error (issue #747, unit test)

Checks (unit test) - merge_cells(spreadsheet_id, sheet="Sheet1", range="Sheet1!A1") → {"error": "Invalid A1 notation: Sheet1!A1"}, not an uncaught ValueError - unmerge_cells(spreadsheet_id, sheet="Sheet1", range="Sheet1!A1") → same error shape - Neither call makes a batchUpdate API call - Covered by test_invalid_range_returns_error_without_api_call in TestMergeCells and TestUnmergeCells

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Error text now repr-quotes the range; TC wording stale.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S118: update_borders — cheap param validation now runs before the sheet lookup (unit test)

Checks (unit test) - update_borders(spreadsheet_id, sheet="NoSuchSheet", range="A1", top={"style": "SQUIGGLY"}) → {"error": "Invalid border style 'SQUIGGLY' for 'top'. Must be one of: ..."}, not {"error": "Sheet 'NoSuchSheet' not found"} — no spreadsheets().get() call is made at all - Previously update_borders resolved the sheet name (a network/cache round trip) before checking whether any border params were provided or validating style, unlike add_chart/resize_rows/resize_columns which validate cheap params first — a bad sheet name paired with an invalid style masked the more fundamental problem (issue #330) - Covered by test_validation_runs_before_sheet_lookup in TestUpdateBorders

Result (2026-09-13) ✅ — unit tests pass (158/158). Live-verified via mcp-gee-sweet-kit against the QA fixture spreadsheet: update_borders(sheet="NoSuchSheet", range="A1", top={"style": "SQUIGGLY"}) returned {"error": "Invalid border style 'SQUIGGLY' for 'top'. Must be one of: ..."}, not the sheet-not-found error — confirming validation still runs before the sheet lookup after PR #738 round 2's dedup fix (_enum_value_error now takes an explicit context string rather than auto-quoting).

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S125: update_borders — invalid A1 notation range returns a clean error (issue #747, unit test)

Checks (unit test) - update_borders(spreadsheet_id, sheet="Sheet1", range="Sheet1!A1", top={"style": "SOLID"}) → {"error": "Invalid A1 notation: Sheet1!A1"}, not an uncaught ValueError - No batchUpdate API call is made - Covered by test_invalid_range_returns_error_without_api_call in TestUpdateBorders

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Error text now repr-quotes the range; TC wording stale.


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) ⚠️ PARTIAL (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. Corrected (2026-10-17, #1044 audit): the dropdown rendering wasn't checked.

Result (2026-09-04) ⚠️ PARTIAL 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) Corrected (2026-10-17, #1044 audit): the dropdown rendering wasn't checked.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run on the Empty sheet; dropdown/checkbox rendering seen, values confirmed via API read-back.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run on the Empty sheet; dropdown/checkbox rendering seen, values confirmed via API read-back.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run on the Empty sheet; dropdown/checkbox rendering seen, values confirmed via API read-back.


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) → []

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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: ..."}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S122: add_data_validation — values count/shape mismatch per condition_type returns a local error (unit test)

Background: Issue #366 — before this, a mismatched values count reached the real Sheets API and got a clean 400 back (e.g. NUMBER_BETWEEN with 1 value: "requires exactly two ConditionValues, but 1 value was supplied"), which was already safe, just a round-trip later than necessary. Now caught locally via _condition_value_count_error before any API call, covering all five value-count shapes: zero-only (DATE_IS_VALID, TEXT_IS_EMAIL, TEXT_IS_URL; BLANK/NOT_BLANK were removed in PR #986 as unsupported by data validation, see TC-S134), zero-or-two (BOOLEAN), exactly-one (NUMBER_GREATER, CUSTOM_FORMULA, ONE_OF_RANGE, and the other single-value TEXT_/DATE_/NUMBER_* types), exactly-two (NUMBER_BETWEEN, NUMBER_NOT_BETWEEN, DATE_BETWEEN, DATE_NOT_BETWEEN), and at-least-one (ONE_OF_LIST).

Checks (unit test) - NUMBER_BETWEEN with 1 value → {"error": ...}, before any API call (the issue's own example) - BOOLEAN accepts 0 or 2 values (a plain checkbox or custom checked/unchecked labels), rejects exactly 1 - A zero-value type (DATE_IS_VALID) rejects any supplied value - ONE_OF_LIST rejects an empty/omitted values - Exactly-one types (NUMBER_GREATER, CUSTOM_FORMULA, ONE_OF_RANGE) reject 0 or 2 values - Exactly-two types (NUMBER_BETWEEN, DATE_BETWEEN) reject 1 or 3 values - Covered by TestAddDataValidation::test_value_count_mismatch_returns_error_without_api_call, test_boolean_accepts_two_custom_label_values, test_boolean_rejects_exactly_one_value, test_zero_value_condition_type_rejects_a_value, test_one_of_list_requires_at_least_one_value, test_exactly_one_value_types_reject_zero_or_two, test_exactly_two_value_types_reject_one_or_three

Result (2026-09-13) ✅ PASS (live spot-check) — ran add_data_validation(Empty!H1:H5, NUMBER_BETWEEN, ["5"]) (the issue's own example) against the fixture spreadsheet on the running server (not just the mocked unit tests): returned {"error": "condition_type 'NUMBER_BETWEEN' requires exactly 2 value(s), got 1"} with no sheet mutation, confirming the local check is wired up end-to-end.

Re-verified (2026-09-13) ✅ PASS — after PR #750's KeyError-guard fix (commit 475e420): re-ran the same NUMBER_BETWEEN call against the reconnected server, still returns the clean local error with no regression.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Error is nested as {"result":{"error":…}} (list-typed output schema); TC says {"error"}.


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)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Error is nested as {"result":{"error":…}} (list-typed output schema); TC says {"error"}.


TC-S126: add_data_validation — invalid A1 notation range returns a clean error (issue #747, unit test)

Checks (unit test) - add_data_validation(spreadsheet_id, sheet="Sheet1", range="Sheet1!A1", condition_type="DATE_IS_VALID") → {"error": "Invalid A1 notation: Sheet1!A1"}, not an uncaught ValueError - No batchUpdate API call is made - Covered by test_invalid_range_returns_error_without_api_call in TestAddDataValidation — exercises the shared _apply_data_validation helper's _grid_range call

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Error text now repr-quotes the range; TC wording stale.


clear_data_validation

TC-S119: clear_data_validation removes an existing rule ⚠️ destructive

Background: Issue #365 — before this tool, the only way to remove a data validation rule was delete_sheet + create_sheet on the whole sheet. clear_data_validation calls setDataValidation with no rule, which the Sheets API documents as clearing any existing rule on the range.

Prompt (direct tool call)

  1. add_data_validation(spreadsheet_id=<fixture>, sheet="Empty", range="E1:E5", condition_type="ONE_OF_LIST", values=["Yes","No","Maybe"])
  2. clear_data_validation(spreadsheet_id=<fixture>, sheet="Empty", range="E1:E5")
  3. get_data_validation(spreadsheet_id=<fixture>, sheet="Empty", range="E1:E5")

Checks - Step 1 succeeds (sets up the rule to clear) - Step 2 returns no error - Step 3 (get_data_validation) returns [] — the rule set in step 1 is gone

Result (2026-09-13) ✅ PASS Ran live against the fixture spreadsheet. add_data_validation set the ONE_OF_LIST rule on E1:E5 with no error; clear_data_validation returned no error; get_data_validation on E1:E5 returned [] afterward — confirms the tool's core premise (setDataValidation with rule omitted actually clears an existing rule against the real Sheets API, not just in the mocked unit tests).

Re-verified (2026-09-13) ✅ PASS — after PR #745's _apply_data_validation dedup refactor (commit 931363a): re-ran the same set/clear/confirm sequence on G1:G5 directly against the reconnected server. add_data_validation still sets the rule correctly, clear_data_validation still clears it (get_data_validation → [] afterward) — no behavior change from the extraction.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S120: clear_data_validation on a range with no existing rule is a no-op

Prompt (direct tool call)

clear_data_validation(spreadsheet_id=<fixture>, sheet="Empty", range="F1:F5") — a range that has never had a validation rule set.

Checks - Returns no error (clearing an already-clear range is not itself an error condition) - get_data_validation(spreadsheet_id=<fixture>, sheet="Empty", range="F1:F5") still returns [] afterward

Result (2026-09-13) ✅ PASS Confirmed F1:F5 had no rule beforehand (get_data_validation → []), then clear_data_validation returned no error, then get_data_validation still returned [].

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S121: clear_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 TestClearDataValidation::test_returns_error_when_sheet_not_found

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S127: clear_data_validation — invalid A1 notation range returns a clean error (issue #747, unit test)

Checks (unit test) - clear_data_validation(spreadsheet_id, sheet="Sheet1", range="Sheet1!A1") → {"error": "Invalid A1 notation: Sheet1!A1"}, not an uncaught ValueError - No batchUpdate API call is made - Covered by test_invalid_range_returns_error_without_api_call in TestClearDataValidation — exercises the same shared _apply_data_validation helper as TC-S126

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Error text now repr-quotes the range; TC wording stale.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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"}

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


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) ❌ FAIL (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. Corrected (2026-10-17, #1044 audit): the round was sent back for raw KeyError/TypeError/HttpError leaks in the same validation; fixed in round 2 (5648a91), re-confirmed 2026-09-04.

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)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S128: sort_range — invalid A1 notation range returns a clean error (issue #747, unit test)

Checks (unit test) - sort_range(spreadsheet_id, sheet="Sheet1", range="Sheet1!A1") → {"error": "Invalid A1 notation: Sheet1!A1"}, not an uncaught ValueError - No batchUpdate API call is made - Covered by test_invalid_range_returns_error_without_api_call in TestSortRange

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Error text now repr-quotes the range; TC wording stale.


TC-S129: _parse_a1_notation rejects semantically-invalid ranges — row 0, and end-before-start ordering (issue #747 QA round 1, unit test)

Background: TC-S123–TC-S128 and TC-W40 cover syntactically malformed A1 notation (e.g. Sheet1!A1). QA round 1 on PR #756 found _parse_a1_notation also accepted syntactically-valid-but-semantically-invalid strings without raising: a row of 0 (regex-matched by the bare \d+, but not a valid 1-based A1 row) silently computed startRowIndex = -1, and an inverted range like A5:A2 produced endRowIndex < startRowIndex — both bypassed the try/except this PR added at every call site and would still surface as a raw Sheets API HttpError, the exact failure class #747 is about, just for different malformed inputs. Fixed directly in _parse_a1_notation (not per call site) — it now raises ValueError for a row below 1, or an end bound at or before its start bound, so the fix flows through the try/except infrastructure already in place everywhere.

Checks (unit test) - _parse_a1_notation("A0") → raises ValueError (row 0) - _parse_a1_notation("A1:A0") → raises ValueError (end row 0) - _parse_a1_notation("A5:A2") → raises ValueError (inverted row range) - _parse_a1_notation("A5:A4") → raises ValueError (zero-width row range — generalized beyond the reported inverted case, since both represent "no valid rows selected") - _parse_a1_notation("C1:A1") → raises ValueError (inverted column range) - _parse_a1_notation("B1:A1") → raises ValueError (zero-width column range) - Every existing valid-input test in TestParseA1Notation still passes unchanged (no false positives on well-formed ranges, open-ended ranges, or bare column/row forms) - Covered by test_row_zero_raises, test_end_row_zero_raises, test_inverted_row_range_raises, test_zero_width_row_range_raises, test_inverted_column_range_raises, test_zero_width_column_range_raises in TestParseA1Notation (tests/sheets/test_helpers.py)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S130: A1-notation error handling factored into a shared _or_error helper, not duplicated per call site (issue #747 QA round 1, unit test)

Background: QA round 1 also found the try: ... except ValueError as e: return {"error": str(e)} block this PR added was identically duplicated at 8 call sites (2 in data.py, 6 in structure.py), instead of factored into a shared helper matching this codebase's established error-or-None convention (_enum_value_error, _positive_value_error, _non_negative_value_error, _resolve_end_index_or_error). Fixed via _parse_a1_notation_or_error (helpers.py) and _grid_range_or_error (structure.py), each returning (error, None) on failure or (None, result) on success — every call site (add_data_validation/clear_data_validation via _apply_data_validation, format_cells, update_borders, merge_cells, unmerge_cells, sort_range, add_chart, and both update_cells sites) now calls the shared helper instead of wrapping the raw _parse_a1_notation/_grid_range itself — including add_chart, which had its own pre-existing inline try/except predating this PR.

Separately, update_cells's two _parse_a1_notation call sites (mixed-cell branch and rich-text branch) were consolidated into one: both branches key off the same range string, and rich_text_cells being non-empty is exactly the condition under which either branch needs the parse, so it's now done once, up front, and reused — closing the "unreachable in practice" redundancy QA round 1 flagged as not blocking on its own (TC-W40's own description was updated to match).

Checks (unit test) - No try/except ValueError remains anywhere in data.py or structure.py outside _parse_a1_notation_or_error/_grid_range_or_error themselves - All TC-S123–TC-S130/TC-W40 error-path unit tests still pass through the shared helpers - uv run python -m pytest tests/sheets/ passes in full

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S133: GridRange tools reject an out-of-bounds or malformed range before any API call (issue #955)

Background: _parse_a1_notation had no upper limit on column letters or row numbers, so ZZZZZZZZ1 or A99999999999 reached the Sheets API and came back as a raw HttpError, and a row string over 4300 digits made int() raise without the Invalid A1 notation prefix. Separately, sort_range, merge_cells, unmerge_cells, format_cells, update_borders, add_data_validation and clear_data_validation resolved the sheet ID, which is an API call on a cold cache, before parsing the range. #955 rejects columns past ZZZ and rows past 10,000,000 (Sheets' grid limits) locally, and parses the range before the sheet lookup. The nonexistent spreadsheet ID and sheet name below prove the ordering: a build that looks up the sheet first returns a 404 or Sheet 'NoSuchSheet' not found, not the A1 error.

Fixture: none. spreadsheet_id="tc-s133-no-such-spreadsheet", sheet="NoSuchSheet".

Prompt

"Call each of these with spreadsheet_id=\"tc-s133-no-such-spreadsheet\" and sheet=\"NoSuchSheet\", and report each raw result: 1. sort_range(range=\"ZZZZZZZZ1\") 2. merge_cells(range=\"A99999999999\") 3. unmerge_cells(range=\"A1:AAAA2\") 4. format_cells(range=\"A:5\", bold=True) 5. update_borders(range=\"A:B5\", top={\"style\": \"SOLID\"}) 6. add_data_validation(range=\"\\u212a1\", condition_type=\"DATE_IS_VALID\") (the Kelvin sign U+212A, then 1) 7. clear_data_validation(range=\"A10000001\") 8. sort_range(range=\"ZZZ10000000\")"

Checks - 1 → {"error": "Invalid A1 notation: column is beyond ZZZ: 'ZZZZZZZZ1'"} - 2 → {"error": "Invalid A1 notation: row is beyond 10,000,000: 'A99999999999'"} - 3 → {"error": "Invalid A1 notation: column is beyond ZZZ: 'A1:AAAA2'"} - 4 → {"error": "Invalid A1 notation: 'A:5'"} - 5 → {"error": "Invalid A1 notation: 'A:B5'"} - 6 → {"error": "Invalid A1 notation: 'K1'"} (the error repr shows the Kelvin sign, not an ASCII K) - 7 → {"error": "Invalid A1 notation: row is beyond 10,000,000: 'A10000001'"} - 8 (control: the largest valid cell) → not an Invalid A1 notation error. It reaches the sheet lookup, so it fails on the fake spreadsheet ID instead (an HttpError 404 or a Sheet ... not found error) - None of calls 1–7 returns a 404 or a Sheet 'NoSuchSheet' not found error

Unit coverage: TestGridRangeToolsRejectMalformedRange (tests/sheets/test_structure.py) runs every malformed case against all seven tools and asserts neither spreadsheets().get() nor batchUpdate() is called. test_column_beyond_zzz_raises, test_row_beyond_limit_raises, test_row_over_int_digit_limit_keeps_prefix and test_last_column_and_row_accepted in TestParseA1Notation (tests/sheets/test_helpers.py) cover the bounds.

Result (2026-10-11, PR #986 round 1, 6d180ec) ✅ PASS. Live via mcp-gee-sweet-sky after /mcp reconnect: - 1 → {"error":"Invalid A1 notation: column is beyond ZZZ: 'ZZZZZZZZ1'"} - 2 → {"error":"Invalid A1 notation: row is beyond 10,000,000: 'A99999999999'"} - 3 → {"error":"Invalid A1 notation: column is beyond ZZZ: 'A1:AAAA2'"} - 4 → {"error":"Invalid A1 notation: 'A:5'"} - 5 → {"error":"Invalid A1 notation: 'A:B5'"} - 6 → twice returned the HttpError 404 from the sheet lookup. The Kelvin sign didn't survive the agent's own tool-call input: the call went out as ASCII K1, a valid cell. A fresh interpreter on this PR's code confirms _parse_a1_notation("K1") raises Invalid A1 notation: 'K1' while ASCII K1 parses (and "ff1" raises). When an agent drives this case, verify step 6 that way, not through the tool call. - 7 → {"error":"Invalid A1 notation: row is beyond 10,000,000: 'A10000001'"} - 8 (control) → HttpError 404 from the sheet lookup, as expected - None of calls 1–5 or 7 returned a 404 or Sheet ... not found error.

#953 checks (folded into #955's pass), same round. - Bogus-ID preflight: format_cells(range="A1") → HttpError 404 (control). A1:, 1:B, ß1, A١ → {"error":"Invalid A1 notation: '<range>'"} each, no 404. - No write on a real sheet (temporary sky-pr986-scratch tab in mcp-gee-sweet-qa-fixtures, seeded A1:B6 with unsorted values, deleted afterward): sort_range(range="A1:") and merge_cells(range="5:") each returned the A1 error; a grid-data read afterward showed the original order and no merges. - format_cells regression (bold applied, bold cells read back with a fresh interpreter, reset between ranges, 1000×26 grid): A1 → A1 only; A1:B2 → rows 1–2, cols A–B (4); A:A → rows 1–1000, col A; 1:2 → rows 1–2, cols A–Z; B2:D → rows 2–1000, cols B–D; B2:5 → rows 2–5, cols B–Z; b2:d3 → rows 2–3, cols B–D (6). All exact. - API contract: get_sheet_data(range="A:B5") is accepted by the Sheets values API, which reads it as A5:B (returned rows 5–6 of the seeded A:B data). The GridRange tools' rejection of A:B5 is therefore stricter than Sheets; filed as #987.

Result (2026-10-11, PR #986 round 2, da34415) ✅ PASS (sent back for the BLANK/NOT_BLANK finding below). Live via mcp-gee-sweet-sky after /mcp reconnect, verifying fix commit da34415 (_resolve_grid_range): - TC-S133 calls 1–5, 7 → same Invalid A1 notation errors as round 1; add_data_validation(range="A1:") → Invalid A1 notation: 'A1:'; call 8 (control) → HttpError 404 from the sheet lookup. - sheetId lands on the named tab, not sheet 0: on a temporary non-first tab (sky-pr986-r2, index 3, deleted afterward), format_cells("B1:C2", bold), merge_cells("E1:F2"), add_data_validation("H1:H3", ONE_OF_LIST) and update_borders("J1:J2", top SOLID) each landed exactly there, and the Sales tab (sheet 0) showed no bold, merges, borders or validation. unmerge_cells("E1:F2") removed the merge; clear_data_validation("H1:H2") left only H3. - sort_range's rebuilt sort specs: sort_range("B1:C4", [{"column_index": 1, "order": "DESCENDING"}]) sorted by column C (dimensionIndex = start column B + 1), giving c/4, a/3, d/2, b/1. - Side note (pre-existing, sent back to the Dev in round 2): add_data_validation with condition_type="BLANK" or "NOT_BLANK" reaches the API and fails with HttpError 400 … ConditionType 'NOT_BLANK' is not supported in data validation. TC-S133 step 6 still works because the range is rejected before the request is built. Corrected (2026-10-17, #1044 audit): outcome of the BLANK/NOT_BLANK finding: round 3 (44f9360) removed both from the condition-type enum, and TC-S134 verifies the clean local error.

Result (2026-10-11, PR #986 round 3, 44f9360) ✅ PASS. Live via mcp-gee-sweet-sky after /mcp reconnect: - 1–5, 7 → the same Invalid A1 notation errors as round 1, word for word. - 6 (now DATE_IS_VALID): the Kelvin sign again can't be sent through an agent's tool call, so it was checked in a fresh interpreter: _parse_a1_notation("K1") raises Invalid A1 notation: 'K1'. Live, add_data_validation(range="A1:", condition_type="DATE_IS_VALID") → {"error":"Invalid A1 notation: 'A1:'"}, so the new condition type passes the enum check and the range is still rejected before the sheet lookup. - 8 (control) → HttpError 404 from the sheet lookup. - None of calls 1–5 or 7 returned a 404 or Sheet ... not found error.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.


TC-S134: add_data_validation rejects condition types data validation doesn't support, before any API call (PR #986 QA round 2)

Background: add_data_validation accepted BLANK, NOT_BLANK, TEXT_STARTS_WITH and TEXT_ENDS_WITH, but the Sheets API supports these only for conditional formatting and filters (each one's BooleanCondition.type enum description in the discovery doc omits data validation). Every call came back as HttpError 400 … ConditionType '<type>' is not supported in data validation. A fresh-interpreter probe of setDataValidation confirmed all four are rejected and ONE_OF_LIST is accepted. All four are now removed from _VALID_CONDITION_TYPES and _CONDITION_SPECS, so _enum_value_error rejects them locally. The nonexistent spreadsheet ID below shows the rejection is local: a build that still accepts them fails at the sheet lookup with a 404.

Fixture: none. spreadsheet_id="tc-s134-no-such-spreadsheet", sheet="NoSuchSheet", range="A1".

Prompt

"Call add_data_validation(spreadsheet_id=\"tc-s134-no-such-spreadsheet\", sheet=\"NoSuchSheet\", range=\"A1\", condition_type=<type>) for each <type> below, with values=[\"ab\"] for the two TEXT_ types and no values for the others, and report each raw result: 1. BLANK 2. NOT_BLANK 3. TEXT_STARTS_WITH 4. TEXT_ENDS_WITH 5. DATE_IS_VALID (control)"

Checks - 1–4 → {"error": "Invalid condition_type '<type>'. Must be one of: …"}, and the listed valid types don't include any of the four - 5 (control: a supported zero-value type) → not an Invalid condition_type error. It reaches the sheet lookup and fails on the fake spreadsheet ID (an HttpError 404 or a Sheet ... not found error) - None of calls 1–4 returns a 404 or a Sheet ... not found error - The add_data_validation tool description served by the MCP server (its condition_type list) no longer names any of the four types

Unit coverage: TestAddDataValidation::test_condition_type_unsupported_by_data_validation_rejected_locally (tests/sheets/test_structure.py) asserts the local error and that neither spreadsheets().get() nor batchUpdate() is called.

Result (2026-10-11, PR #986 round 3, 44f9360) ✅ PASS. Live via mcp-gee-sweet-sky after /mcp reconnect: - 1 BLANK, 2 NOT_BLANK, 3 TEXT_STARTS_WITH (values=["ab"]), 4 TEXT_ENDS_WITH (values=["ab"]) → {"error":"Invalid condition_type '<type>'. Must be one of: BOOLEAN, TEXT_CONTAINS, TEXT_NOT_CONTAINS, TEXT_EQ, TEXT_IS_EMAIL, TEXT_IS_URL, DATE_EQ, … ONE_OF_RANGE, ONE_OF_LIST, CUSTOM_FORMULA"} each. The valid-type list names none of the four. - 5 DATE_IS_VALID (control) → HttpError 404 from the sheet lookup on the fake ID. - None of calls 1–4 returned a 404. - The served add_data_validation description (re-fetched after the reconnect) lists DATE_IS_VALID, TEXT_CONTAINS/TEXT_NOT_CONTAINS/TEXT_EQ, TEXT_IS_EMAIL/TEXT_IS_URL and the rest, and none of the four removed types.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 2 (kai-oauth): every check matched; visual cases checked under the Playwright mutex.