|
1 | 1 | import { describe, it, expect, beforeEach, vi } from 'vitest'; |
2 | | -import { SchemaRegistry, applySystemFields, reconcileManagedApiMethods, warnStrippedLegacyApiMethods, computeFQN, parseFQN } from './registry'; |
| 2 | +import { SchemaRegistry, applySystemFields, reconcileManagedApiMethods, warnStrippedLegacyApiMethods, warnFunctionalCompleteness, computeFQN, parseFQN } from './registry'; |
3 | 3 | import { AUDIT_PROVENANCE_FIELDS } from '@objectstack/spec/data'; |
4 | 4 |
|
5 | 5 | describe('SchemaRegistry', () => { |
@@ -924,3 +924,102 @@ describe('warnStrippedLegacyApiMethods (#3543)', () => { |
924 | 924 | expect(warn).toHaveBeenCalledTimes(1); |
925 | 925 | }); |
926 | 926 | }); |
| 927 | + |
| 928 | +// ========================================== |
| 929 | +// warnFunctionalCompleteness — ADR-0078 Phase 4 |
| 930 | +// Registration-time twin of `validate-functional-completeness`: the registry |
| 931 | +// is the one choke point every metadata door goes through, including the ones |
| 932 | +// that skip Zod and lint (#3896, raw registerObject). Same shared predicate, |
| 933 | +// same rule ids. Pure observation — never mutates the schema, never throws. |
| 934 | +// ========================================== |
| 935 | +describe('warnFunctionalCompleteness (ADR-0078 Phase 4)', () => { |
| 936 | + it('diagnoses a bare summary field with the SAME rule id the lint reports', () => { |
| 937 | + const warn = vi.fn(); |
| 938 | + warnFunctionalCompleteness( |
| 939 | + { name: 'fc_room', fields: { registration_count: { type: 'summary', label: 'Registrations' } } } as any, |
| 940 | + { warn }, |
| 941 | + ); |
| 942 | + expect(warn).toHaveBeenCalledTimes(1); |
| 943 | + const msg = warn.mock.calls[0][0] as string; |
| 944 | + expect(msg).toContain('fc_room'); |
| 945 | + expect(msg).toContain('registration_count'); |
| 946 | + expect(msg).toContain('field/summary-without-operations'); |
| 947 | + expect(msg).toContain('ADR-0078'); |
| 948 | + // The prescription rides along — a warning with no fix is a dead end. |
| 949 | + expect(msg).toContain('summaryOperations'); |
| 950 | + }); |
| 951 | + |
| 952 | + it('aggregates every inert field into ONE line (greppable, not spam)', () => { |
| 953 | + const warn = vi.fn(); |
| 954 | + warnFunctionalCompleteness( |
| 955 | + { |
| 956 | + name: 'fc_multi', |
| 957 | + fields: { |
| 958 | + total: { type: 'summary' }, |
| 959 | + rate: { type: 'formula' }, |
| 960 | + acct: { type: 'lookup' }, |
| 961 | + ok: { type: 'text', label: 'Fine' }, |
| 962 | + }, |
| 963 | + } as any, |
| 964 | + { warn }, |
| 965 | + ); |
| 966 | + expect(warn).toHaveBeenCalledTimes(1); |
| 967 | + const msg = warn.mock.calls[0][0] as string; |
| 968 | + expect(msg).toContain('field/summary-without-operations'); |
| 969 | + expect(msg).toContain('field/formula-without-expression'); |
| 970 | + expect(msg).toContain('field/relationship-without-reference'); |
| 971 | + expect(msg).not.toContain('" ok:'); // the healthy field is not named |
| 972 | + }); |
| 973 | + |
| 974 | + it('stays silent for a complete schema — the predicate decides, not this wrapper', () => { |
| 975 | + const warn = vi.fn(); |
| 976 | + warnFunctionalCompleteness( |
| 977 | + { |
| 978 | + name: 'fc_clean', |
| 979 | + fields: { |
| 980 | + total: { type: 'summary', summaryOperations: { object: 'line', field: 'amt', function: 'sum' } }, |
| 981 | + acct: { type: 'lookup', reference: 'account' }, |
| 982 | + stage: { type: 'select', options: [{ label: 'New', value: 'new' }] }, |
| 983 | + tags: { type: 'multiselect' }, // the pinned NON-rule stays a NON-rule here too |
| 984 | + }, |
| 985 | + } as any, |
| 986 | + { warn }, |
| 987 | + ); |
| 988 | + expect(warn).not.toHaveBeenCalled(); |
| 989 | + }); |
| 990 | + |
| 991 | + it('stays silent for fieldless / malformed schemas (never the thing that crashes a boot)', () => { |
| 992 | + const warn = vi.fn(); |
| 993 | + warnFunctionalCompleteness({ name: 'fc_nofields' } as any, { warn }); |
| 994 | + warnFunctionalCompleteness({ name: 'fc_badfields', fields: 'nope' } as any, { warn }); |
| 995 | + expect(warn).not.toHaveBeenCalled(); |
| 996 | + }); |
| 997 | + |
| 998 | + it('warns only once per object name (hot path stays free)', () => { |
| 999 | + const warn = vi.fn(); |
| 1000 | + const schema: any = { name: 'fc_once', fields: { t: { type: 'summary' } } }; |
| 1001 | + warnFunctionalCompleteness(schema, { warn }); |
| 1002 | + warnFunctionalCompleteness(schema, { warn }); |
| 1003 | + expect(warn).toHaveBeenCalledTimes(1); |
| 1004 | + }); |
| 1005 | + |
| 1006 | + it('fires through registerObject — the choke point every door shares', () => { |
| 1007 | + // The integration half: a raw registerObject call (no Zod, no lint — |
| 1008 | + // the #3896 class of door) still gets the diagnostic. |
| 1009 | + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 1010 | + try { |
| 1011 | + const registry = new SchemaRegistry(); |
| 1012 | + registry.registerObject( |
| 1013 | + { name: 'fc_via_register', label: 'X', fields: { dead: { type: 'formula' } } } as any, |
| 1014 | + 'test-pkg', |
| 1015 | + ); |
| 1016 | + const hit = spy.mock.calls.find( |
| 1017 | + (c) => typeof c[0] === 'string' && (c[0] as string).includes('fc_via_register'), |
| 1018 | + ); |
| 1019 | + expect(hit).toBeDefined(); |
| 1020 | + expect(hit![0]).toContain('field/formula-without-expression'); |
| 1021 | + } finally { |
| 1022 | + spy.mockRestore(); |
| 1023 | + } |
| 1024 | + }); |
| 1025 | +}); |
0 commit comments