|
18 | 18 | import { describe, expect, it } from 'vitest'; |
19 | 19 | import { |
20 | 20 | judgePicklistReferences, |
| 21 | + picklistRefusalHeading, |
21 | 22 | PICKLIST_REFERENCE_UNKNOWN, |
22 | 23 | PICKLIST_REFERENCE_UNVERIFIED, |
| 24 | + PICKLIST_RESIDUAL_UNREFERENCED, |
23 | 25 | } from './picklist-references.js'; |
24 | 26 |
|
25 | 27 | const manifest = (id: string, extra: Record<string, unknown> = {}) => ({ |
@@ -329,3 +331,116 @@ describe('judgePicklistReferences — a `picklistExtensions[].extend` in a `pack |
329 | 331 | })).toEqual({ refusals: [], notices: [] }); |
330 | 332 | }); |
331 | 333 | }); |
| 334 | + |
| 335 | +/** |
| 336 | + * [ADR-0130 D4] A composed stack's top-level picklist that no package body |
| 337 | + * declares and no object field the boot registers names: the boot refuses the |
| 338 | + * stack (`INVALID_METADATA`), so this door refuses it too, as |
| 339 | + * `picklist-residual-unreferenced`, in the boot's words. The boot's own text is |
| 340 | + * held equal in `picklist-references.boot-parity.test.ts`. |
| 341 | + */ |
| 342 | +describe('judgePicklistReferences — a composed stack\'s residual picklist that nothing names', () => { |
| 343 | + const STACK_ID = 'com.example.release'; |
| 344 | + const status = { name: 'status_list', label: 'Status', options: [{ label: 'Open', value: 'open' }] }; |
| 345 | + const unused = { name: 'unused_list', label: 'Unused', options: [{ label: 'A', value: 'a' }] }; |
| 346 | + const statusField = { status: { type: 'select', label: 'Status', picklist: 'status_list' } }; |
| 347 | + const caseObj = (fields: Record<string, unknown> = { subject: { type: 'text', label: 'Subject' } }) => |
| 348 | + ({ name: 'pk_case', label: 'Case', fields }); |
| 349 | + const noteObj = (fields: Record<string, unknown> = { title: { type: 'text', label: 'Title' } }) => |
| 350 | + ({ name: 'pk_note', label: 'Note', fields }); |
| 351 | + /** Two bodies, a manifest naming neither, and `top` on the top level. */ |
| 352 | + const composed = (top: Record<string, unknown>, coreExtra: Record<string, unknown> = {}) => ({ |
| 353 | + manifest: manifest(STACK_ID), |
| 354 | + ...top, |
| 355 | + packages: [ |
| 356 | + { manifest: { ...manifest('com.example.core'), objects: [caseObj()], ...coreExtra } }, |
| 357 | + { manifest: { ...manifest('com.example.app'), objects: [account('industry')], picklists: [industry] } }, |
| 358 | + ], |
| 359 | + }); |
| 360 | + const residualRefusals = (parsed: Record<string, unknown>) => |
| 361 | + judgePicklistReferences(parsed).refusals.filter((r) => r.rule === PICKLIST_RESIDUAL_UNREFERENCED); |
| 362 | + |
| 363 | + it('refuses one, naming the list, the stack and the boot\'s prescription, located at its top-level index', () => { |
| 364 | + const { refusals, notices } = judgePicklistReferences(composed({ picklists: [industry, unused] })); |
| 365 | + expect(notices).toEqual([]); |
| 366 | + expect(refusals).toHaveLength(1); |
| 367 | + expect(refusals[0]).toMatchObject({ |
| 368 | + severity: 'error', |
| 369 | + rule: PICKLIST_RESIDUAL_UNREFERENCED, |
| 370 | + where: 'picklist "unused_list"', |
| 371 | + path: 'picklists[1]', |
| 372 | + }); |
| 373 | + expect(refusals[0].message).toContain(`Picklist 'unused_list' is declared on the top level of stack '${STACK_ID}'`); |
| 374 | + expect(refusals[0].message).toContain('no package body declares it and no object field the engine registers names it'); |
| 375 | + expect(refusals[0].hint).toContain('Declare it inside the `packages[]` entry of the package whose objects use it'); |
| 376 | + expect(refusals[0].hint).toContain('name it from a field of the object meant to use it'); |
| 377 | + expect(refusals[0].hint).toContain('remove it from the top level'); |
| 378 | + }); |
| 379 | + |
| 380 | + it('refuses each such list as its own finding', () => { |
| 381 | + expect(residualRefusals(composed({ picklists: [unused, { ...unused, name: 'spare_list' }] })).map((r) => r.where)) |
| 382 | + .toEqual(['picklist "unused_list"', 'picklist "spare_list"']); |
| 383 | + }); |
| 384 | + |
| 385 | + it('refuses one that only a top-level `objectExtensions` entry names — no door registers it in a composed stack', () => { |
| 386 | + expect(residualRefusals(composed({ picklists: [status], objectExtensions: [{ extend: 'pk_case', fields: statusField }] }))) |
| 387 | + .toHaveLength(1); |
| 388 | + }); |
| 389 | + |
| 390 | + it('refuses one that only a top-level copy of a body-declared object names, its fields diverging from the body\'s', () => { |
| 391 | + expect(residualRefusals(composed({ picklists: [status], objects: [caseObj(statusField)] }))).toHaveLength(1); |
| 392 | + }); |
| 393 | + |
| 394 | + it('refuses one that only a top-level `plugins[]` entry\'s object names', () => { |
| 395 | + expect(residualRefusals(composed({ picklists: [status], plugins: [{ name: 'p', objects: [noteObj(statusField)] }] }))) |
| 396 | + .toHaveLength(1); |
| 397 | + }); |
| 398 | + |
| 399 | + it('refuses one that only a body\'s `picklistExtensions` entry extends', () => { |
| 400 | + expect(residualRefusals(composed({ picklists: [status] }, { |
| 401 | + picklistExtensions: [{ extend: 'status_list', options: [{ label: 'Closed', value: 'closed' }] }], |
| 402 | + }))).toHaveLength(1); |
| 403 | + }); |
| 404 | + |
| 405 | + it('CONTROL: one a residual object\'s field names is not this refusal (the boot lists that object and does not serve it)', () => { |
| 406 | + expect(judgePicklistReferences(composed({ picklists: [status], objects: [noteObj(statusField)] }))) |
| 407 | + .toEqual({ refusals: [], notices: [] }); |
| 408 | + }); |
| 409 | + |
| 410 | + it('CONTROL: one a body object\'s field names is not this refusal — that field is `picklist-reference-unknown`, as before', () => { |
| 411 | + const { refusals } = judgePicklistReferences(composed({ picklists: [status] }, { objects: [caseObj(statusField)] })); |
| 412 | + expect(refusals.map((r) => r.rule)).toEqual([PICKLIST_REFERENCE_UNKNOWN]); |
| 413 | + }); |
| 414 | + |
| 415 | + it('CONTROL: a body-declared picklist, repeated on the top level or not, is not residual', () => { |
| 416 | + for (const top of [{ picklists: [industry] }, {}]) { |
| 417 | + expect(judgePicklistReferences(composed(top))).toEqual({ refusals: [], notices: [] }); |
| 418 | + } |
| 419 | + }); |
| 420 | + |
| 421 | + it('CONTROL: a stack without `packages[]` has no residual — its unnamed picklist is not refused', () => { |
| 422 | + expect(judgePicklistReferences({ manifest: manifest('com.example.pk'), picklists: [industry, unused], objects: [account('industry')] })) |
| 423 | + .toEqual({ refusals: [], notices: [] }); |
| 424 | + }); |
| 425 | +}); |
| 426 | + |
| 427 | +describe('picklistRefusalHeading — the text face\'s heading names what each rule refused', () => { |
| 428 | + const finding = (rule: string) => ({ severity: 'error' as const, rule, where: 'w', path: 'p', message: 'm', hint: 'h' }); |
| 429 | + |
| 430 | + it('CONTROL: reference refusals alone keep the heading they always had', () => { |
| 431 | + expect(picklistRefusalHeading([finding(PICKLIST_REFERENCE_UNKNOWN), finding(PICKLIST_REFERENCE_UNKNOWN)])) |
| 432 | + .toBe('A picklist reference names a picklist this stack does not declare (2 references)'); |
| 433 | + }); |
| 434 | + |
| 435 | + it('a residual refusal is headed as the boot\'s refusal, not as a reference', () => { |
| 436 | + const heading = picklistRefusalHeading([finding(PICKLIST_RESIDUAL_UNREFERENCED)]); |
| 437 | + expect(heading).toBe('A top-level picklist of this composed stack is one the boot refuses: no package body declares it ' |
| 438 | + + 'and no object field the engine registers names it (1 picklist)'); |
| 439 | + }); |
| 440 | + |
| 441 | + it('both kinds together are both named', () => { |
| 442 | + const heading = picklistRefusalHeading([finding(PICKLIST_REFERENCE_UNKNOWN), finding(PICKLIST_RESIDUAL_UNREFERENCED)]); |
| 443 | + expect(heading).toContain('(1 reference)'); |
| 444 | + expect(heading).toContain('(1 picklist)'); |
| 445 | + }); |
| 446 | +}); |
0 commit comments