You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
200 lines
6.3 KiB
200 lines
6.3 KiB
/**
|
|
* Phase 2 of the codex refactor: intent policy unified through
|
|
* `SEMA_FAMILY_POLICY`. The previous valenced/transitional split
|
|
* incorrectly forbade intent on transitional families AND required it on
|
|
* every valenced family. The tests below pin the post-canon doctrine:
|
|
*
|
|
* - any family may carry intent unless its policy says otherwise
|
|
* - `SEMA_FAMILY_POLICY[family].intentPolicy === 'expected'` is the
|
|
* only branch that REQUIRES intent
|
|
* - `normalizeSemaEvent` preserves intent on transitional families
|
|
* (e.g. `emerge` carrying `'threat'`)
|
|
* - `parseSemaEventLabel` accepts every `<family>-<intent>` combination
|
|
* including transitional ones
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
SEMA_EVENT_LABELS,
|
|
SEMA_FAMILIES,
|
|
isSemaEvent,
|
|
isSemaEventLabel,
|
|
normalizeSemaEvent,
|
|
parseSemaEventLabel,
|
|
resolveIntent
|
|
} from './event';
|
|
import { SEMA_FAMILY_POLICY } from './types';
|
|
|
|
describe('SEMA_EVENT_LABELS', () => {
|
|
it('includes family-only forms for every family', () => {
|
|
for (const family of SEMA_FAMILIES) {
|
|
expect(SEMA_EVENT_LABELS).toContain(family);
|
|
}
|
|
});
|
|
|
|
it('includes family-intent combinations for every family + intent (incl. transitional)', () => {
|
|
expect(SEMA_EVENT_LABELS).toContain('emerge-threat');
|
|
expect(SEMA_EVENT_LABELS).toContain('shift-fulfill');
|
|
expect(SEMA_EVENT_LABELS).toContain('sustain-loss');
|
|
expect(SEMA_EVENT_LABELS).toContain('contact-neutral');
|
|
});
|
|
});
|
|
|
|
describe('isSemaEventLabel', () => {
|
|
it('accepts transitional + intent combinations', () => {
|
|
expect(isSemaEventLabel('emerge-threat')).toBe(true);
|
|
expect(isSemaEventLabel('shift-fulfill')).toBe(true);
|
|
});
|
|
|
|
it('accepts family-only forms', () => {
|
|
expect(isSemaEventLabel('emerge')).toBe(true);
|
|
expect(isSemaEventLabel('contact')).toBe(true);
|
|
});
|
|
|
|
it('rejects malformed strings', () => {
|
|
expect(isSemaEventLabel('frob')).toBe(false);
|
|
expect(isSemaEventLabel('emerge-frob')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isSemaEvent — policy-driven', () => {
|
|
it('requires intent on `expected` families (commit, signal)', () => {
|
|
for (const family of ['commit', 'signal'] as const) {
|
|
expect(SEMA_FAMILY_POLICY[family].intentPolicy).toBe('expected');
|
|
expect(isSemaEvent({ family })).toBe(false);
|
|
expect(isSemaEvent({ family, intent: 'fulfill' })).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('allows intent absence on `allowed` families (contact, handle)', () => {
|
|
for (const family of ['contact', 'handle'] as const) {
|
|
expect(SEMA_FAMILY_POLICY[family].intentPolicy).toBe('allowed');
|
|
expect(isSemaEvent({ family })).toBe(true);
|
|
expect(isSemaEvent({ family, intent: 'risk' })).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('allows intent on `optional` families (emerge, shift, sustain) — the post-canon fix', () => {
|
|
for (const family of ['emerge', 'shift', 'sustain'] as const) {
|
|
expect(SEMA_FAMILY_POLICY[family].intentPolicy).toBe('optional');
|
|
expect(isSemaEvent({ family })).toBe(true);
|
|
expect(isSemaEvent({ family, intent: 'threat' })).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('accepts intent bindings (fromProp shape) on any policy', () => {
|
|
const binding = { fromProp: 'intent', default: 'neutral', supported: ['neutral', 'threat'] } as const;
|
|
expect(isSemaEvent({ family: 'commit', intent: binding })).toBe(true);
|
|
expect(isSemaEvent({ family: 'emerge', intent: binding })).toBe(true);
|
|
});
|
|
|
|
it('rejects malformed intent values', () => {
|
|
expect(isSemaEvent({ family: 'commit', intent: 'frob' })).toBe(false);
|
|
expect(isSemaEvent({ family: 'emerge', intent: 42 })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('parseSemaEventLabel', () => {
|
|
it('splits transitional with intent into family + intent', () => {
|
|
const parsed = parseSemaEventLabel('emerge-threat');
|
|
expect(parsed).toEqual({ family: 'emerge', intent: 'threat' });
|
|
});
|
|
|
|
it('splits valenced with intent into family + intent', () => {
|
|
const parsed = parseSemaEventLabel('commit-fulfill');
|
|
expect(parsed).toEqual({ family: 'commit', intent: 'fulfill' });
|
|
});
|
|
|
|
it('parses family-only forms', () => {
|
|
const parsed = parseSemaEventLabel('emerge');
|
|
expect(parsed).toEqual({ family: 'emerge' });
|
|
expect('intent' in parsed).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('normalizeSemaEvent — preserves intent on any family', () => {
|
|
it('preserves intent on transitional families (emerge with threat)', () => {
|
|
const result = normalizeSemaEvent({ family: 'emerge', intent: 'threat' });
|
|
expect(result).toEqual({
|
|
family: 'emerge',
|
|
intent: 'threat',
|
|
label: 'emerge-threat'
|
|
});
|
|
});
|
|
|
|
it('keeps family-only label when intent is absent', () => {
|
|
const result = normalizeSemaEvent({ family: 'contact' });
|
|
expect(result).toEqual({
|
|
family: 'contact',
|
|
intent: null,
|
|
label: 'contact'
|
|
});
|
|
});
|
|
|
|
it('resolves bindings against props', () => {
|
|
const result = normalizeSemaEvent(
|
|
{
|
|
family: 'emerge',
|
|
intent: { fromProp: 'intent', default: 'neutral', supported: ['neutral', 'threat'] }
|
|
},
|
|
{ intent: 'threat' }
|
|
);
|
|
expect(result.label).toBe('emerge-threat');
|
|
expect(result.intent).toBe('threat');
|
|
});
|
|
|
|
it('falls back to binding default when prop is absent', () => {
|
|
const result = normalizeSemaEvent(
|
|
{
|
|
family: 'commit',
|
|
intent: { fromProp: 'intent', default: 'fulfill', supported: ['fulfill', 'threat'] }
|
|
},
|
|
{}
|
|
);
|
|
expect(result.label).toBe('commit-fulfill');
|
|
});
|
|
|
|
it('parses string labels symmetrically', () => {
|
|
expect(normalizeSemaEvent('emerge-threat')).toEqual({
|
|
family: 'emerge',
|
|
intent: 'threat',
|
|
label: 'emerge-threat'
|
|
});
|
|
expect(normalizeSemaEvent('emerge')).toEqual({
|
|
family: 'emerge',
|
|
intent: null,
|
|
label: 'emerge'
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('resolveIntent', () => {
|
|
it('returns the literal when intent is already a string', () => {
|
|
expect(resolveIntent('threat')).toBe('threat');
|
|
});
|
|
|
|
it('reads `fromProp` value when present and supported', () => {
|
|
const intent = resolveIntent(
|
|
{ fromProp: 'intent', default: 'neutral', supported: ['neutral', 'risk', 'threat'] },
|
|
{ intent: 'risk' }
|
|
);
|
|
expect(intent).toBe('risk');
|
|
});
|
|
|
|
it('falls back to `default` when prop is missing', () => {
|
|
const intent = resolveIntent(
|
|
{ fromProp: 'intent', default: 'fulfill', supported: ['fulfill', 'threat'] },
|
|
{}
|
|
);
|
|
expect(intent).toBe('fulfill');
|
|
});
|
|
|
|
it('falls back to first supported when default is unsupported', () => {
|
|
const intent = resolveIntent(
|
|
{ default: 'loss', supported: ['neutral', 'threat'] } as never,
|
|
{}
|
|
);
|
|
expect(intent).toBe('neutral');
|
|
});
|
|
});
|