@yeyuan98/opencode-bioresearcher-plugin 1.6.8-alpha.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,7 +38,7 @@ Comprehensive test suite for bioresearcher plugin tools and skills.
38
38
  | Category | Tools | Status |
39
39
  |----------|-------|--------|
40
40
  | Parser | `parse_pubmed_articleSet`, `parse_obo_file` | Full tests |
41
- | Table | All 13 `table*` tools | Full tests + negative tests |
41
+ | Table | All 13 `table*` tools | Full tests + negative tests + hardening tests + large table tests |
42
42
  | JSON | `jsonExtract`, `jsonValidate`, `jsonInfer` | Full tests |
43
43
  | Misc | `blockingTimer`, `calculator` | Full tests |
44
44
  | Skills | `demo-skill` | Full execution |
@@ -97,15 +97,16 @@ Record pass/fail for each test.
97
97
 
98
98
  ### Step 4: Run Table Tests
99
99
 
100
- Execute tests from `<skill_path>/test_cases/table_tests.md`:
100
+ Execute tests from `<skill_path>/test_cases/table_tests.md` in order:
101
101
 
102
+ **Basic functionality (13 tests):**
102
103
  1. `tableListSheets` - List sheets in sample Excel
103
104
  2. `tableGetSheetPreview` - Get first 6 rows
104
105
  3. `tableGetHeaders` - Get column headers
105
106
  4. `tableGetCell` - Get specific cell value
106
107
  5. `tableGetRange` - Get A1:C3 range
107
- 6. `tableFilterRows` - Filter by Age > 25
108
- 7. `tableSearch` - Search for "test"
108
+ 6. `tableFilterRows` - Filter by Age > 25 (verify new `total_matches_found` + `returned_count` fields)
109
+ 7. `tableSearch` - Search for "Active"
109
110
  8. `tableSummarize` - Get stats for numeric columns
110
111
  9. `tableGroupBy` - Group by Category
111
112
  10. `tableCreateFile` - Create new Excel file
@@ -113,6 +114,47 @@ Execute tests from `<skill_path>/test_cases/table_tests.md`:
113
114
  12. `tableUpdateCell` - Update cell A1
114
115
  13. `tablePivotSummary` - Create pivot table
115
116
 
117
+ **Error / negative tests (5 tests):**
118
+ 14. Nonexistent File - error handling
119
+ 15. Invalid Cell Address - descriptive error message
120
+ 16. Invalid Filter Operator - error handling
121
+ 17. Nonexistent Column - graceful handling
122
+ 18. Out of Bounds Range - empty data
123
+
124
+ **Phase 2 correctness tests (10 tests):**
125
+ 19. Pivot Avg Numeric-Only Denominator - Setup (create test file)
126
+ 20. Pivot Avg Numeric-Only Denominator - Verify (avg = 20.0, not 10.0)
127
+ 21. Pivot Max Handles Negative Values - Setup (create test file)
128
+ 22. Pivot Max Handles Negative Values - Verify (max = 3, not 0)
129
+ 23. CreateFile Overwrite Guard - rejects without `overwrite: true`
130
+ 24. CreateFile Overwrite Succeeds - works with `overwrite: true`
131
+ 25. FilterRows New Response Fields - `total_matches_found` + `returned_count`
132
+ 26. UpdateCell Invalid Address Path Traversal - rejects `../../etc/passwd`
133
+ 27. UpdateCell Null Value - accepts null without error
134
+ 28. CreateFile Invalid Data Schema - rejects non-array data
135
+
136
+ **Hardening feature tests (8 tests):**
137
+ 29. Force Parameter - Filter Rows (regression check)
138
+ 30. Force Parameter - Summarize (regression check)
139
+ 31. File Size Limit - Reject Large File (generate 55MB via `dd`)
140
+ 32. File Size Limit - Bypass With Force (same file, `force: true`)
141
+ 33. Range Limit - Reject Oversized Range (`A1:ZZZ1000`)
142
+ 34. Range Limit - Bypass With Force
143
+ 35. Error Sanitization - No Resolved Paths (no `/home/`, `/root/`, `/tmp/`)
144
+ 36. Error Sanitization - Uses Original Path
145
+
146
+ **Large table tests (8 tests):**
147
+ 37. Large Table - Generate Via Python (`openpyxl` row duplication, 150K+ rows)
148
+ 38. Large Table - Filter Rows Truncation (verify `truncated: true`)
149
+ 39. Large Table - Filter Rows With Force (no truncation)
150
+ 40. Large Table - Summarize Truncation
151
+ 41. Large Table - Search Truncation
152
+ 42. Large Table - GroupBy Truncation
153
+ 43. Large Table - PivotSummary Truncation
154
+ 44. Large Table - Cleanup (remove all generated test files)
155
+
156
+ > **Important:** The large table tests (35-42) require `openpyxl` to be installed. If not available, install with `pip install openpyxl`. The large table is generated during the test and cleaned up afterward - no large files are shipped with the plugin.
157
+
116
158
  Record pass/fail for each test.
117
159
 
118
160
  ### Step 5: Run JSON Tests
@@ -1,5 +1,7 @@
1
1
  # Table Tool Tests
2
2
 
3
+ ## Basic Functionality Tests
4
+
3
5
  ## Test: List Sheets
4
6
  - Tool: tableListSheets
5
7
  - Input:
@@ -64,8 +66,9 @@
64
66
  ```
65
67
  - Validators:
66
68
  - has matched_rows
67
- - has total_matches
68
- - Expected: Filtered rows where Age > 25
69
+ - has total_matches_found
70
+ - has returned_count
71
+ - Expected: Filtered rows where Age > 25 with new response fields
69
72
 
70
73
  ## Test: Search
71
74
  - Tool: tableSearch
@@ -75,8 +78,9 @@
75
78
  ```
76
79
  - Validators:
77
80
  - has results array
78
- - has total_found
79
- - Expected: Cells containing "Active"
81
+ - has total_matches_found
82
+ - has returned_count
83
+ - Expected: Cells containing "Active" with new response fields
80
84
 
81
85
  ## Test: Summarize
82
86
  - Tool: tableSummarize
@@ -143,6 +147,8 @@
143
147
  - has columns array
144
148
  - Expected: Pivot table with Category rows, Status columns, Score sums
145
149
 
150
+ ## Error / Negative Tests
151
+
146
152
  ## Test: Nonexistent File
147
153
  - Tool: tableListSheets
148
154
  - Input:
@@ -161,17 +167,20 @@
161
167
  ```
162
168
  - Validators:
163
169
  - contains_string("Error")
164
- - Expected: Error response for invalid address
170
+ - contains_string("Invalid cell address")
171
+ - Expected: Error response with descriptive message
165
172
 
166
- ## Test: Invalid Filter Operator
173
+ ## Test: No Match Filter
167
174
  - Tool: tableFilterRows
168
175
  - Input:
169
176
  ```json
170
- {"file_path": ".bioresearcher-tests/workspace/table_sample.xlsx", "column": "Age", "operator": "INVALID", "value": 25, "max_results": 100}
177
+ {"file_path": ".bioresearcher-tests/workspace/table_sample.xlsx", "column": "Age", "operator": ">", "value": 999999, "max_results": 100}
171
178
  ```
172
179
  - Validators:
173
- - contains_string("Error")
174
- - Expected: Error response for invalid operator
180
+ - has total_matches_found
181
+ - has returned_count
182
+ - has matched_rows
183
+ - Expected: Zero matches returned (valid query with no matching rows)
175
184
 
176
185
  ## Test: Nonexistent Column
177
186
  - Tool: tableSummarize
@@ -192,3 +201,332 @@
192
201
  - Validators:
193
202
  - has data array
194
203
  - Expected: Empty data array (out of bounds handled)
204
+
205
+ ## Phase 2 Correctness Tests
206
+
207
+ ## Test: Pivot Avg Numeric-Only Denominator - Setup
208
+ - Tool: tableCreateFile
209
+ - Input:
210
+ ```json
211
+ {"file_path": ".bioresearcher-tests/workspace/pivot_avg_test.xlsx", "sheet_name": "Sheet1", "data": [{"Group": "A", "Type": "X", "Val": 10}, {"Group": "A", "Type": "X", "Val": ""}, {"Group": "A", "Type": "X", "Val": 30}, {"Group": "B", "Type": "Y", "Val": 20}, {"Group": "B", "Type": "Y", "Val": 40}]}
212
+ ```
213
+ - Validators:
214
+ - success_is_true
215
+ - Expected: File created for pivot avg test
216
+
217
+ ## Test: Pivot Avg Numeric-Only Denominator - Verify
218
+ - Tool: tablePivotSummary
219
+ - Prerequisite: Test "Pivot Avg Numeric-Only Denominator - Setup" must pass first
220
+ - Input:
221
+ ```json
222
+ {"file_path": ".bioresearcher-tests/workspace/pivot_avg_test.xlsx", "row_field": "Group", "col_field": "Type", "value_field": "Val", "agg": "avg"}
223
+ ```
224
+ - Validators:
225
+ - has pivot object
226
+ - Expected: Group A avg = 20.0 (10+30 divided by 2, not 3)
227
+
228
+ ## Test: Pivot Max Handles Negative Values - Setup
229
+ - Tool: tableCreateFile
230
+ - Input:
231
+ ```json
232
+ {"file_path": ".bioresearcher-tests/workspace/pivot_neg_test.xlsx", "sheet_name": "Sheet1", "data": [{"Group": "A", "Val": -5}, {"Group": "A", "Val": -10}, {"Group": "A", "Val": 3}]}
233
+ ```
234
+ - Validators:
235
+ - success_is_true
236
+ - Expected: File created for pivot neg test
237
+
238
+ ## Test: Pivot Max Handles Negative Values - Verify
239
+ - Tool: tablePivotSummary
240
+ - Prerequisite: Test "Pivot Max Handles Negative Values - Setup" must pass first
241
+ - Input:
242
+ ```json
243
+ {"file_path": ".bioresearcher-tests/workspace/pivot_neg_test.xlsx", "row_field": "Group", "col_field": "Group", "value_field": "Val", "agg": "max"}
244
+ ```
245
+ - Validators:
246
+ - has pivot object
247
+ - Expected: Max is 3, not 0
248
+
249
+ ## Test: CreateFile Overwrite Guard
250
+ - Prerequisite: Test "Create File" must pass first (output_created.xlsx exists)
251
+ - Tool: tableCreateFile
252
+ - Input:
253
+ ```json
254
+ {"file_path": ".bioresearcher-tests/workspace/output_created.xlsx", "sheet_name": "Sheet1", "data": [{"Name": "Override"}]}
255
+ ```
256
+ - Validators:
257
+ - contains_string("already exists")
258
+ - contains_string("overwrite: true")
259
+ - Expected: Error because file exists and overwrite is not set
260
+
261
+ ## Test: CreateFile Overwrite Succeeds
262
+ - Prerequisite: Test "CreateFile Overwrite Guard" must run first
263
+ - Tool: tableCreateFile
264
+ - Input:
265
+ ```json
266
+ {"file_path": ".bioresearcher-tests/workspace/output_created.xlsx", "sheet_name": "Sheet1", "data": [{"Name": "Overwritten"}], "overwrite": true}
267
+ ```
268
+ - Validators:
269
+ - success_is_true
270
+ - rows_created === 1
271
+ - Expected: File overwritten successfully
272
+
273
+ ## Test: FilterRows New Response Fields
274
+ - Tool: tableFilterRows
275
+ - Input:
276
+ ```json
277
+ {"file_path": ".bioresearcher-tests/workspace/table_sample.xlsx", "column": "Age", "operator": ">=", "value": 0, "max_results": 5}
278
+ ```
279
+ - Validators:
280
+ - has total_matches_found
281
+ - has returned_count
282
+ - has matched_rows
283
+ - Expected: Response includes total_matches_found and returned_count (replaces old total_matches)
284
+
285
+ ## Test: UpdateCell Invalid Address Path Traversal
286
+ - Tool: tableUpdateCell
287
+ - Input:
288
+ ```json
289
+ {"file_path": ".bioresearcher-tests/workspace/output_created.xlsx", "cell_address": "../../etc/passwd", "value": "test"}
290
+ ```
291
+ - Validators:
292
+ - contains_string("Error")
293
+ - contains_string("Invalid cell address")
294
+ - Expected: Error response rejecting path traversal in cell address
295
+
296
+ ## Test: UpdateCell Null Value
297
+ - Prerequisite: Test "CreateFile Overwrite Succeeds" must pass first
298
+ - Tool: tableUpdateCell
299
+ - Input:
300
+ ```json
301
+ {"file_path": ".bioresearcher-tests/workspace/output_created.xlsx", "cell_address": "B1", "value": null}
302
+ ```
303
+ - Validators:
304
+ - success_is_true
305
+ - Expected: Cell updated with null value without error
306
+
307
+ ## Test: CreateFile Invalid Data Schema
308
+ - Tool: tableCreateFile
309
+ - Input:
310
+ ```json
311
+ {"file_path": ".bioresearcher-tests/workspace/invalid_data.xlsx", "sheet_name": "Sheet1", "data": "not an array"}
312
+ ```
313
+ - Validators:
314
+ - contains_string("Error")
315
+ - contains_string("must be an array")
316
+ - Expected: Error because data is a string, not an array
317
+
318
+ ## Hardening Feature Tests
319
+
320
+ ## Test: Force Parameter - Filter Rows
321
+ - Tool: tableFilterRows
322
+ - Input:
323
+ ```json
324
+ {"file_path": ".bioresearcher-tests/workspace/table_sample.xlsx", "column": "Age", "operator": ">", "value": 25, "max_results": 100, "force": true}
325
+ ```
326
+ - Validators:
327
+ - has matched_rows
328
+ - has total_matches_found
329
+ - has returned_count
330
+ - Expected: Same results as Filter Rows without force (regression check)
331
+
332
+ ## Test: Force Parameter - Summarize
333
+ - Tool: tableSummarize
334
+ - Input:
335
+ ```json
336
+ {"file_path": ".bioresearcher-tests/workspace/table_sample.xlsx", "columns": ["Age", "Score"], "force": true}
337
+ ```
338
+ - Validators:
339
+ - has summaries object
340
+ - Expected: Same summaries as Summarize without force (regression check)
341
+
342
+ ## Test: File Size Limit - Reject Large File
343
+ - Setup: Run the following Bash command to create a 55MB dummy file:
344
+ ```bash
345
+ dd if=/dev/urandom of=.bioresearcher-tests/workspace/large_dummy.xlsx bs=1M count=55 2>/dev/null && echo "Created large file"
346
+ ```
347
+ - Tool: tableListSheets
348
+ - Input:
349
+ ```json
350
+ {"file_path": ".bioresearcher-tests/workspace/large_dummy.xlsx"}
351
+ ```
352
+ - Validators:
353
+ - contains_string("Error")
354
+ - contains_string("exceeding")
355
+ - contains_string("force: true")
356
+ - Expected: Error rejecting file over 50MB limit with guidance to use force
357
+
358
+ ## Test: File Size Limit - Bypass With Force
359
+ - Prerequisite: Test "File Size Limit - Reject Large File" must run first (large_dummy.xlsx exists)
360
+ - Tool: tableListSheets
361
+ - Input:
362
+ ```json
363
+ {"file_path": ".bioresearcher-tests/workspace/large_dummy.xlsx", "force": true}
364
+ ```
365
+ - Validators:
366
+ - has sheets array
367
+ - Expected: Size check bypassed with force: true; tool processed the file (parser may return partial results for corrupt data)
368
+ - Cleanup: Run `rm .bioresearcher-tests/workspace/large_dummy.xlsx`
369
+
370
+ ## Test: Range Limit - Reject Oversized Range
371
+ - Tool: tableGetRange
372
+ - Input:
373
+ ```json
374
+ {"file_path": ".bioresearcher-tests/workspace/table_sample.xlsx", "range": "A1:ZZZ1000"}
375
+ ```
376
+ - Validators:
377
+ - contains_string("Error")
378
+ - contains_string("exceeds")
379
+ - contains_string("force: true")
380
+ - Expected: Error rejecting range exceeding 50K cell limit
381
+
382
+ ## Test: Range Limit - Bypass With Force
383
+ - Tool: tableGetRange
384
+ - Input:
385
+ ```json
386
+ {"file_path": ".bioresearcher-tests/workspace/table_sample.xlsx", "range": "A1:ZZZ1000", "force": true}
387
+ ```
388
+ - Validators:
389
+ - has data array
390
+ - Expected: Data array returned (mostly empty cells, but range limit bypassed)
391
+
392
+ ## Test: Error Sanitization - No Resolved Paths
393
+ - Tool: tableGetCell
394
+ - Input:
395
+ ```json
396
+ {"file_path": ".bioresearcher-tests/workspace/nonexistent_file.xlsx", "cell_address": "A1"}
397
+ ```
398
+ - Validators:
399
+ - contains_string("Error")
400
+ - not contains_string("/home/")
401
+ - not contains_string("/root/")
402
+ - not contains_string("/tmp/")
403
+ - Expected: Error message does not leak absolute filesystem paths
404
+
405
+ ## Test: Error Sanitization - Uses Original Path
406
+ - Tool: tableListSheets
407
+ - Input:
408
+ ```json
409
+ {"file_path": ".bioresearcher-tests/workspace/nonexistent_file.xlsx"}
410
+ ```
411
+ - Validators:
412
+ - contains_string("Error")
413
+ - contains_string(".bioresearcher-tests/workspace/nonexistent_file.xlsx")
414
+ - Expected: Error message references the original user-provided file path
415
+
416
+ ## Large Table Tests
417
+
418
+ ## Test: Large Table - Generate Via Python
419
+ - Setup: Run the following Python command to generate a table with 150K+ rows by duplicating the sample data:
420
+ ```bash
421
+ pip install openpyxl -q 2>/dev/null; python3 -c "
422
+ import openpyxl
423
+ wb = openpyxl.load_workbook('.bioresearcher-tests/workspace/table_sample.xlsx')
424
+ ws = wb.active
425
+ rows = list(ws.iter_rows(values_only=True))
426
+ data_rows = rows[1:]
427
+ if not data_rows:
428
+ print('ERROR: no data rows found')
429
+ else:
430
+ target = 150000
431
+ for i in range(target // len(data_rows)):
432
+ for row in data_rows:
433
+ ws.append(row)
434
+ wb.save('.bioresearcher-tests/workspace/large_table.xlsx')
435
+ print(f'Created table with {ws.max_row} rows')
436
+ "
437
+ ```
438
+ - Validators:
439
+ - contains_string("Created table with")
440
+ - Expected: large_table.xlsx created with 150K+ rows
441
+
442
+ ## Test: Large Table - Filter Rows Truncation
443
+ - Prerequisite: Test "Large Table - Generate Via Python" must pass first
444
+ - Tool: tableFilterRows
445
+ - Input:
446
+ ```json
447
+ {"file_path": ".bioresearcher-tests/workspace/large_table.xlsx", "column": "Age", "operator": ">", "value": 0, "max_results": 50}
448
+ ```
449
+ - Validators:
450
+ - has matched_rows
451
+ - has total_matches_found
452
+ - has returned_count
453
+ - truncated === true
454
+ - contains_string("Set force: true")
455
+ - Expected: Results truncated at row cap with guidance message
456
+
457
+ ## Test: Large Table - Filter Rows With Force
458
+ - Prerequisite: Test "Large Table - Generate Via Python" must pass first
459
+ - Tool: tableFilterRows
460
+ - Input:
461
+ ```json
462
+ {"file_path": ".bioresearcher-tests/workspace/large_table.xlsx", "column": "Age", "operator": ">", "value": 0, "max_results": 50, "force": true}
463
+ ```
464
+ - Validators:
465
+ - has matched_rows
466
+ - has total_matches_found
467
+ - has returned_count
468
+ - Expected: All rows processed, no truncation field
469
+
470
+ ## Test: Large Table - Summarize Truncation
471
+ - Prerequisite: Test "Large Table - Generate Via Python" must pass first
472
+ - Tool: tableSummarize
473
+ - Input:
474
+ ```json
475
+ {"file_path": ".bioresearcher-tests/workspace/large_table.xlsx", "columns": ["Age"]}
476
+ ```
477
+ - Validators:
478
+ - has summaries object
479
+ - truncated === true
480
+ - has processed_rows
481
+ - contains_string("Set force: true")
482
+ - Expected: Summary computed on capped rows with truncation notice
483
+
484
+ ## Test: Large Table - Search Truncation
485
+ - Prerequisite: Test "Large Table - Generate Via Python" must pass first
486
+ - Tool: tableSearch
487
+ - Input:
488
+ ```json
489
+ {"file_path": ".bioresearcher-tests/workspace/large_table.xlsx", "search_term": "Active", "max_results": 10}
490
+ ```
491
+ - Validators:
492
+ - has results array
493
+ - has total_matches_found
494
+ - has returned_count
495
+ - truncated === true
496
+ - has rows_scanned
497
+ - Expected: Search scanned up to 100K rows, counted all matches, returned limited results
498
+
499
+ ## Test: Large Table - GroupBy Truncation
500
+ - Prerequisite: Test "Large Table - Generate Via Python" must pass first
501
+ - Tool: tableGroupBy
502
+ - Input:
503
+ ```json
504
+ {"file_path": ".bioresearcher-tests/workspace/large_table.xlsx", "group_column": "Category", "agg_column": "Score", "agg_type": "avg"}
505
+ ```
506
+ - Validators:
507
+ - has groups object
508
+ - truncated === true
509
+ - has processed_rows
510
+ - Expected: GroupBy truncated at row cap
511
+
512
+ ## Test: Large Table - PivotSummary Truncation
513
+ - Prerequisite: Test "Large Table - Generate Via Python" must pass first
514
+ - Tool: tablePivotSummary
515
+ - Input:
516
+ ```json
517
+ {"file_path": ".bioresearcher-tests/workspace/large_table.xlsx", "row_field": "Category", "col_field": "Status", "value_field": "Score", "agg": "sum"}
518
+ ```
519
+ - Validators:
520
+ - has pivot object
521
+ - truncated === true
522
+ - has processed_rows
523
+ - Expected: Pivot truncated at row cap
524
+
525
+ ## Test: Large Table - Cleanup
526
+ - Setup: Run the following Bash command to remove the large table:
527
+ ```bash
528
+ rm -f .bioresearcher-tests/workspace/large_table.xlsx .bioresearcher-tests/workspace/large_dummy.xlsx .bioresearcher-tests/workspace/pivot_avg_test.xlsx .bioresearcher-tests/workspace/pivot_neg_test.xlsx .bioresearcher-tests/workspace/invalid_data.xlsx .bioresearcher-tests/workspace/output_created.xlsx && echo "Cleanup done"
529
+ ```
530
+ - Validators:
531
+ - contains_string("Cleanup done")
532
+ - Expected: All generated test files removed
@@ -376,9 +376,17 @@ def validate_result(result: Any, validators: list[str]) -> tuple[bool, list[dict
376
376
  detail = f"has matched_rows: {passed}"
377
377
 
378
378
  elif validator == "has total_matches":
379
- passed = "total_matches" in result_obj
379
+ passed = "total_matches_found" in result_obj or "total_matches" in result_obj
380
380
  detail = f"has total_matches: {passed}"
381
381
 
382
+ elif validator == "has total_matches_found":
383
+ passed = "total_matches_found" in result_obj
384
+ detail = f"has total_matches_found: {passed}"
385
+
386
+ elif validator == "has returned_count":
387
+ passed = "returned_count" in result_obj
388
+ detail = f"has returned_count: {passed}"
389
+
382
390
  elif validator == "has results array":
383
391
  passed = "results" in result_obj and isinstance(result_obj["results"], list)
384
392
  detail = f"has results: {passed}"
@@ -405,10 +413,37 @@ def validate_result(result: Any, validators: list[str]) -> tuple[bool, list[dict
405
413
  passed = "columns" in result_obj and isinstance(result_obj["columns"], list)
406
414
  detail = f"has columns: {passed}"
407
415
 
416
+ elif validator == "has truncated":
417
+ passed = "truncated" in result_obj
418
+ detail = f"has truncated: {passed}"
419
+
420
+ elif validator == "has processed_rows":
421
+ passed = "processed_rows" in result_obj
422
+ detail = f"has processed_rows: {passed}"
423
+
424
+ elif validator == "has rows_scanned":
425
+ passed = "rows_scanned" in result_obj
426
+ detail = f"has rows_scanned: {passed}"
427
+
428
+ elif validator == "truncated === true":
429
+ passed = result_obj.get("truncated") is True
430
+ detail = f"truncated={result_obj.get('truncated')}"
431
+
432
+ elif validator.startswith("not contains_string"):
433
+ match = re.search(r'"(.+?)"', validator)
434
+ if match:
435
+ substr = match.group(1)
436
+ passed = substr.lower() not in result_str.lower()
437
+ detail = f"does NOT contain '{substr}': {passed}"
438
+
408
439
  elif validator == "rows_appended === 1":
409
440
  passed = result_obj.get("rows_appended") == 1
410
441
  detail = f"rows_appended={result_obj.get('rows_appended')}"
411
442
 
443
+ elif validator == "rows_created === 1":
444
+ passed = result_obj.get("rows_created") == 1
445
+ detail = f"rows_created={result_obj.get('rows_created')}"
446
+
412
447
  elif validator == "rows_created === 2":
413
448
  passed = result_obj.get("rows_created") == 2
414
449
  detail = f"rows_created={result_obj.get('rows_created')}"