import { deriveReviewTransitions, resolveReviewConfig, reviewDecisions, applyDecisionToData, pickStatusAttr, shouldIgnoreShortcut, nextReviewIndex, reviewHostOf, fieldStr, DEFAULT_VERDICTS, } from '../review'; import type { AttributeDef, EntityTypeDef } from '../types'; function enumAttr(name: string, choices: string[]): AttributeDef { return { id: name, name, dataType: 'enum', required: false, config: { choices } }; } function textAttr(name: string): AttributeDef { return { id: name, name, dataType: 'text', required: false, config: {} }; } function type(attrs: AttributeDef[]): EntityTypeDef { return { id: 'topic', key: 'topic', label: 'Topic', attributes: attrs }; } describe('deriveReviewTransitions', () => { it('maps the canonical OGMC pipeline (suggested→ready→rejected→written)', () => { const t = deriveReviewTransitions(['suggested', 'ready', 'rejected', 'written']); expect(t.approve).toBe('ready'); expect(t.reject).toBe('rejected'); }); it('skips terminal (published-like) stages when choosing Approve', () => { // No dedicated "ready" — approve should still avoid the terminal "written". const t = deriveReviewTransitions(['new', 'rejected', 'written']); expect(t.reject).toBe('rejected'); // only forward non-terminal, non-reject candidate is none → falls back to the // first successor that isn't the reject stage → 'written' (best available). expect(t.approve).toBe('written'); }); it('detects varied rejection words', () => { expect(deriveReviewTransitions(['open', 'approved', 'declined']).reject).toBe('declined'); expect(deriveReviewTransitions(['inbox', 'live', 'archived']).reject).toBe('archived'); }); it('honours explicit overrides (including nulling a button off)', () => { const t = deriveReviewTransitions(['a', 'b', 'c'], { approve: 'c', reject: null }); expect(t.approve).toBe('c'); expect(t.reject).toBeNull(); }); it('returns nulls for an empty choice set', () => { expect(deriveReviewTransitions([])).toEqual({ approve: null, reject: null }); }); }); describe('pickStatusAttr', () => { it('prefers an attr named "status"', () => { const t = type([enumAttr('kind', ['a', 'b']), enumAttr('status', ['x', 'y'])]); expect(pickStatusAttr(t)?.name).toBe('status'); }); it('falls back to the first enum with choices', () => { const t = type([textAttr('title'), enumAttr('stage', ['x', 'y'])]); expect(pickStatusAttr(t)?.name).toBe('stage'); }); it('returns null when no enum attr has choices', () => { expect(pickStatusAttr(type([textAttr('title')]))).toBeNull(); }); }); describe('resolveReviewConfig', () => { const topic = type([ textAttr('title'), textAttr('angle'), enumAttr('content_type', ['weekly_brief', 'lead_magnet', 'general']), enumAttr('status', ['suggested', 'ready', 'rejected', 'written']), textAttr('market'), textAttr('team_verdict'), textAttr('team_notes'), ]); it('resolves the OGMC defaults from the live schema', () => { const r = resolveReviewConfig(topic); expect(r.statusName).toBe('status'); expect(r.transitions).toEqual({ approve: 'ready', reject: 'rejected' }); expect(r.verdictAttr).toBe('team_verdict'); expect(r.noteAttr).toBe('team_notes'); expect(r.verdicts).toBe(DEFAULT_VERDICTS); }); it('drops meta/source attrs the type does not declare', () => { const r = resolveReviewConfig(topic); // default meta ['content_type','market'] both declared; default sources none declared expect(r.metaAttrs).toEqual(['content_type', 'market']); expect(r.sourceAttrs).toEqual([]); }); it('lets overrides win', () => { const r = resolveReviewConfig(topic, { rejectStatus: 'written', verdictAttr: 'vote' }); expect(r.transitions.reject).toBe('written'); expect(r.verdictAttr).toBe('vote'); }); }); describe('reviewDecisions', () => { const topic = type([ enumAttr('status', ['suggested', 'ready', 'rejected', 'written']), textAttr('team_verdict'), ]); it('maps the three decisions to status + verdict pairs on the OGMC schema', () => { const d = reviewDecisions(resolveReviewConfig(topic)); expect(d.map((x) => x.key)).toEqual(['approve', 'needs_work', 'reject']); expect(d.find((x) => x.key === 'approve')).toMatchObject({ status: 'ready', verdict: 'good', advance: true }); expect(d.find((x) => x.key === 'needs_work')).toMatchObject({ status: 'suggested', verdict: 'edit', advance: false, promptNote: true }); expect(d.find((x) => x.key === 'reject')).toMatchObject({ status: 'rejected', verdict: 'bad', advance: true }); }); it('drops decisions whose status target does not resolve', () => { // two-state enum with no rejection word → no Reject; approve resolves to the successor const twoState = type([enumAttr('status', ['open', 'closed']), textAttr('team_verdict')]); const keys = reviewDecisions(resolveReviewConfig(twoState)).map((x) => x.key); expect(keys).toContain('approve'); expect(keys).not.toContain('reject'); }); it('omitNeedsWork + custom transitions gives a binary accept/reject (news curation)', () => { const news = type([enumAttr('status', ['new', 'surfaced', 'acceptable', 'rejected'])]); const d = reviewDecisions( resolveReviewConfig(news, { approveStatus: 'acceptable', rejectStatus: 'rejected', verdicts: [], omitNeedsWork: true, }), ); expect(d.map((x) => x.key).sort()).toEqual(['approve', 'reject']); expect(d.find((x) => x.key === 'approve')).toMatchObject({ status: 'acceptable', verdict: null }); expect(d.find((x) => x.key === 'reject')).toMatchObject({ status: 'rejected', verdict: null }); }); it('uses custom verdict vocab tones', () => { const d = reviewDecisions( resolveReviewConfig(topic, { verdicts: [ { value: 'yes', label: 'Yes', tone: 'good' }, { value: 'no', label: 'No', tone: 'bad' }, ], }), ); expect(d.find((x) => x.key === 'approve')?.verdict).toBe('yes'); expect(d.find((x) => x.key === 'reject')?.verdict).toBe('no'); // no neutral verdict, but an entry stage exists → Needs work still offered (verdict null) expect(d.find((x) => x.key === 'needs_work')).toMatchObject({ verdict: null }); }); }); describe('applyDecisionToData', () => { const topic = type([ enumAttr('status', ['suggested', 'ready', 'rejected', 'written']), textAttr('team_verdict'), ]); const cfg = resolveReviewConfig(topic); const decisions = reviewDecisions(cfg); const byKey = (k: string) => decisions.find((d) => d.key === k)!; it('writes status + verdict together (camelCased), preserving other keys', () => { const out = applyDecisionToData({ title: 'T', status: 'suggested', teamVerdict: '' }, byKey('approve'), cfg); expect(out.status).toBe('ready'); expect(out.teamVerdict).toBe('good'); expect(out.title).toBe('T'); }); it('reject writes rejected + bad', () => { const out = applyDecisionToData({}, byKey('reject'), cfg); expect(out).toEqual({ status: 'rejected', teamVerdict: 'bad' }); }); it('drops a snake-case duplicate of a camelCased key', () => { const out = applyDecisionToData({ team_verdict: 'old' }, byKey('approve'), cfg); expect(out.teamVerdict).toBe('good'); expect(out.team_verdict).toBeUndefined(); }); }); describe('shouldIgnoreShortcut', () => { it('ignores while typing in a form control', () => { expect(shouldIgnoreShortcut({ key: 'a', target: { tagName: 'INPUT' } })).toBe(true); expect(shouldIgnoreShortcut({ key: 'a', target: { tagName: 'TEXTAREA' } })).toBe(true); expect(shouldIgnoreShortcut({ key: 'a', target: { isContentEditable: true } })).toBe(true); }); it('ignores modified keys', () => { expect(shouldIgnoreShortcut({ key: 'a', metaKey: true, target: { tagName: 'DIV' } })).toBe(true); }); it('fires on a bare letter over non-input', () => { expect(shouldIgnoreShortcut({ key: 'a', target: { tagName: 'DIV' } })).toBe(false); }); }); describe('nextReviewIndex', () => { it('advances to the next item', () => { expect(nextReviewIndex(['a', 'b', 'c'], 'a')).toBe(1); expect(nextReviewIndex(['a', 'b', 'c'], 'b')).toBe(2); }); it('steps back when acting on the last item', () => { expect(nextReviewIndex(['a', 'b', 'c'], 'c')).toBe(1); }); it('returns -1 when the list would empty', () => { expect(nextReviewIndex(['a'], 'a')).toBe(-1); expect(nextReviewIndex([], 'a')).toBe(-1); }); }); describe('reviewHostOf / fieldStr', () => { it('bare-hosts a url with or without scheme', () => { expect(reviewHostOf('https://www.reuters.com/x')).toBe('reuters.com'); expect(reviewHostOf('gov.ae/notice')).toBe('gov.ae'); expect(reviewHostOf('not a url')).toBe(''); }); it('reads camel or snake keys off the data blob', () => { expect(fieldStr({ teamVerdict: 'good' }, 'team_verdict')).toBe('good'); expect(fieldStr({ team_verdict: 'bad' }, 'team_verdict')).toBe('bad'); expect(fieldStr(undefined, 'x')).toBe(''); }); });