|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | import { describe, it, expect } from 'vitest'; |
| 4 | +import { resolveSearchFields } from '@objectstack/spec/data'; |
4 | 5 | import { |
5 | 6 | validateSearchableFields, |
6 | 7 | SEARCHABLE_FIELD_UNKNOWN, |
@@ -475,3 +476,167 @@ describe('validateSearchableFields — list views that narrow the set', () => { |
475 | 476 | expect(findings).toEqual([]); |
476 | 477 | }); |
477 | 478 | }); |
| 479 | + |
| 480 | +/** |
| 481 | + * [#6675] Skill-parity — `skills/objectstack-ui/SKILL.md` › "Toolbar Search |
| 482 | + * (`searchableFields`, ADR-0061)" quotes this rule's two diagnostics verbatim |
| 483 | + * and states three boundaries as fact. The skill ships to third parties via |
| 484 | + * `npx skills add`, so a reader who follows it is following THIS code; if the |
| 485 | + * wording or a verdict moves and nobody re-reads the skill, the published text |
| 486 | + * teaches a rule the platform no longer has. |
| 487 | + * |
| 488 | + * The same reason `validate-rls-predicate-enforceability.test.ts` pins the RLS |
| 489 | + * predicates the data skill prints. Change any assertion here and the skill |
| 490 | + * section is what needs editing, not the assertion. |
| 491 | + */ |
| 492 | +describe('validateSearchableFields — objectstack-ui SKILL.md parity (#6675)', () => { |
| 493 | + /** The object the skill's examples and quoted error texts are written against. */ |
| 494 | + const supportCase = { |
| 495 | + name: 'support_case', |
| 496 | + nameField: 'subject', |
| 497 | + searchableFields: ['subject', 'case_number', 'description'], |
| 498 | + fields: { |
| 499 | + subject: { type: 'text' }, |
| 500 | + case_number: { type: 'autonumber' }, |
| 501 | + description: { type: 'textarea' }, |
| 502 | + status: { type: 'select' }, |
| 503 | + account_id: { type: 'lookup', reference: 'crm_account' }, |
| 504 | + account_name: { type: 'text' }, |
| 505 | + }, |
| 506 | + }; |
| 507 | + |
| 508 | + /** A `defineView` container whose `triage` list narrows the object's set. */ |
| 509 | + const viewStack = (searchableFields: unknown, objectOverrides: Record<string, unknown> = {}) => ({ |
| 510 | + objects: [{ ...supportCase, ...objectOverrides }], |
| 511 | + views: [ |
| 512 | + { |
| 513 | + name: 'support_case', |
| 514 | + objectName: 'support_case', |
| 515 | + list: { |
| 516 | + label: 'All Cases', |
| 517 | + type: 'grid', |
| 518 | + data: { provider: 'object', object: 'support_case' }, |
| 519 | + columns: ['subject', 'status'], |
| 520 | + }, |
| 521 | + listViews: { |
| 522 | + triage: { |
| 523 | + label: 'Triage', |
| 524 | + type: 'grid', |
| 525 | + data: { provider: 'object', object: 'support_case' }, |
| 526 | + columns: ['case_number', 'subject', 'status'], |
| 527 | + ...(searchableFields === undefined ? {} : { searchableFields }), |
| 528 | + }, |
| 529 | + }, |
| 530 | + }, |
| 531 | + ], |
| 532 | + }); |
| 533 | + |
| 534 | + it('the skill\'s `os:check` example lints clean — a subset of the allowed set', () => { |
| 535 | + // SKILL.md: `listViews.triage.searchableFields: ['case_number', 'subject']`. |
| 536 | + expect(validateSearchableFields(viewStack(['case_number', 'subject']))).toEqual([]); |
| 537 | + }); |
| 538 | + |
| 539 | + it('omitting the key lints clean (row 2 of the skill\'s boundary table)', () => { |
| 540 | + expect(validateSearchableFields(viewStack(undefined))).toEqual([]); |
| 541 | + }); |
| 542 | + |
| 543 | + /** |
| 544 | + * The skill states an empty array is identical to omitting the key — the |
| 545 | + * claim an author most needs, because the spelling suggests the opposite. |
| 546 | + * |
| 547 | + * The lint half of it is deliberately NOT the assertion that carries this |
| 548 | + * test. `checkSearchableFieldList` returns early on a zero-length array, and |
| 549 | + * even without that early return the entry loop has nothing to iterate — so |
| 550 | + * "lints clean" is green because nothing was produced, not because the |
| 551 | + * verdict is right, and it cannot go red on a regression. It is asserted |
| 552 | + * below only to pin that no finding appears; the load-bearing assertion is |
| 553 | + * the next one. |
| 554 | + * |
| 555 | + * `resolveSearchFields` is where `[]` acquires meaning: it is the ONE |
| 556 | + * resolution the ingress gate (`assertSearchFieldsAreSearchable`) and the |
| 557 | + * engine (`expandSearchToFilter`) share, so an empty request resolving to |
| 558 | + * the full allowed set IS the runtime behaviour the skill describes. Narrow |
| 559 | + * the fall-through and this goes red. |
| 560 | + */ |
| 561 | + it('`searchableFields: []` is ABSENT, not "search off" — it resolves to the FULL allowed set', () => { |
| 562 | + expect(validateSearchableFields(viewStack([]))).toEqual([]); |
| 563 | + |
| 564 | + const resolutionArgs = { |
| 565 | + fields: supportCase.fields, |
| 566 | + searchableFields: supportCase.searchableFields, |
| 567 | + displayField: supportCase.nameField, |
| 568 | + }; |
| 569 | + // An empty narrowing scans every column the object allows … |
| 570 | + expect(resolveSearchFields({ ...resolutionArgs, requestedFields: [] })) |
| 571 | + .toEqual(['subject', 'case_number', 'description']); |
| 572 | + // … which is exactly what omitting the key does … |
| 573 | + expect(resolveSearchFields(resolutionArgs)) |
| 574 | + .toEqual(['subject', 'case_number', 'description']); |
| 575 | + // … and strictly MORE than a one-entry narrowing, the inversion the skill |
| 576 | + // calls out: `[]` searches wider than `['subject']`. |
| 577 | + expect(resolveSearchFields({ ...resolutionArgs, requestedFields: ['subject'] })) |
| 578 | + .toEqual(['subject']); |
| 579 | + }); |
| 580 | + |
| 581 | + it('quotes the dotted-path diagnostic exactly as the skill prints it', () => { |
| 582 | + const findings = validateSearchableFields(viewStack(['subject', 'account_id.name'])); |
| 583 | + |
| 584 | + expect(findings).toHaveLength(1); |
| 585 | + expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNKNOWN); |
| 586 | + expect(findings[0].severity).toBe('error'); |
| 587 | + expect(findings[0].message).toBe( |
| 588 | + 'list-view searchableFields entry "account_id.name" is not a field on object ' |
| 589 | + + '"support_case". The declaration is stale: searching it can never match, and the ' |
| 590 | + + 'engine silently drops it — leaving a narrower search than declared, or the ' |
| 591 | + + 'auto-default set once every entry is dropped.', |
| 592 | + ); |
| 593 | + }); |
| 594 | + |
| 595 | + it('quotes the outside-the-declared-set diagnostic exactly as the skill prints it', () => { |
| 596 | + const findings = validateSearchableFields(viewStack(['subject', 'status'])); |
| 597 | + |
| 598 | + expect(findings).toHaveLength(1); |
| 599 | + expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE); |
| 600 | + expect(findings[0].severity).toBe('error'); |
| 601 | + expect(findings[0].message).toBe( |
| 602 | + 'list-view searchableFields entry "status" is outside object "support_case"\'s ' |
| 603 | + + 'declared searchableFields (subject, case_number, description) — the set \'search\' ' |
| 604 | + + 'scans. Clients echo this declaration verbatim as the \'$searchFields\' override, ' |
| 605 | + + 'and the runtime refuses an entry outside the allowed set: every toolbar search on ' |
| 606 | + + 'this list returns 400 INVALID_FIELD (#4254).', |
| 607 | + ); |
| 608 | + }); |
| 609 | + |
| 610 | + /** |
| 611 | + * The correction the skill makes to a type-first reading: on an object that |
| 612 | + * DECLARES its set, the declaration is the boundary and the field's type is |
| 613 | + * not consulted — a lookup inside it is scanned, a text column outside it is |
| 614 | + * refused. Both directions, because either alone reads as a coincidence. |
| 615 | + */ |
| 616 | + it('a lookup INSIDE the object\'s declared set is accepted; a text column OUTSIDE it is not', () => { |
| 617 | + const declaresLookup = { searchableFields: ['subject', 'account_id'] }; |
| 618 | + |
| 619 | + expect(validateSearchableFields(viewStack(['account_id'], declaresLookup))).toEqual([]); |
| 620 | + |
| 621 | + const refused = validateSearchableFields(viewStack(['account_name'], declaresLookup)); |
| 622 | + expect(refused).toHaveLength(1); |
| 623 | + expect(refused[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE); |
| 624 | + expect(refused[0].message).toContain('"account_name"'); |
| 625 | + }); |
| 626 | + |
| 627 | + /** |
| 628 | + * …and the mirror image: with NO declaration on the object, the auto-default |
| 629 | + * is the boundary, so type is exactly what decides. `select` is in the |
| 630 | + * text-like set the skill lists; `lookup` is not. |
| 631 | + */ |
| 632 | + it('with no object declaration, the auto-default type list decides', () => { |
| 633 | + const noDeclaration = { searchableFields: undefined }; |
| 634 | + |
| 635 | + expect(validateSearchableFields(viewStack(['subject', 'status'], noDeclaration))).toEqual([]); |
| 636 | + |
| 637 | + const refused = validateSearchableFields(viewStack(['account_id'], noDeclaration)); |
| 638 | + expect(refused).toHaveLength(1); |
| 639 | + expect(refused[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE); |
| 640 | + expect(refused[0].message).toContain("of type 'lookup', which 'search' cannot scan"); |
| 641 | + }); |
| 642 | +}); |
0 commit comments