Rule 1 deviation: TS strict mode (noImplicitAny + strict) couldn't narrow
the EvalResult union via 'r.ok' direct access because the {unknown:true,raw}
variant has no 'ok' property. Added explicit type-guard helpers (isUnknown,
isOk, isFail) for both production and spec narrowing. Runtime behavior
unchanged; tsc --noEmit now exits clean for the leveling lib.
Files:
- prereq-evaluator.ts: 3 type-guard functions, used in OR/AND walkers
- prereq-evaluator.spec.ts: isFail() in lieu of result.ok=== checks
146 lines
5.1 KiB
TypeScript
146 lines
5.1 KiB
TypeScript
import { evaluatePrereq } from './prereq-evaluator';
|
|
import type { CharacterContext, EvalResult } from './types';
|
|
|
|
function isFail(r: EvalResult): r is { ok: false; reason: string } {
|
|
return 'ok' in r && r.ok === false;
|
|
}
|
|
|
|
function makeCtx(overrides: Partial<CharacterContext> = {}): CharacterContext {
|
|
return {
|
|
level: 5,
|
|
className: 'Fighter',
|
|
ancestryName: 'Human',
|
|
heritageName: undefined,
|
|
abilities: { STR: 16, DEX: 14, CON: 14, INT: 10, WIS: 12, CHA: 10 },
|
|
skills: {},
|
|
feats: new Set<string>(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('evaluatePrereq — empty/null', () => {
|
|
it('returns ok for null prereq', () => {
|
|
expect(evaluatePrereq(null, makeCtx())).toEqual({ ok: true });
|
|
});
|
|
|
|
it('returns ok for empty string', () => {
|
|
expect(evaluatePrereq('', makeCtx())).toEqual({ ok: true });
|
|
});
|
|
});
|
|
|
|
describe('evaluatePrereq — skill rank', () => {
|
|
it('returns ok when skill rank meets requirement', () => {
|
|
const ctx = makeCtx({ skills: { Athletics: 'TRAINED' } });
|
|
expect(evaluatePrereq('Trained in Athletics', ctx)).toEqual({ ok: true });
|
|
});
|
|
|
|
it('returns ok when skill rank exceeds requirement', () => {
|
|
const ctx = makeCtx({ skills: { Athletics: 'EXPERT' } });
|
|
expect(evaluatePrereq('Trained in Athletics', ctx)).toEqual({ ok: true });
|
|
});
|
|
|
|
it('returns ok:false with German reason when skill rank below requirement', () => {
|
|
const ctx = makeCtx({ skills: { Athletics: 'UNTRAINED' } });
|
|
const result = evaluatePrereq('Trained in Athletics', ctx);
|
|
expect(isFail(result)).toBe(true);
|
|
if (isFail(result)) {
|
|
expect(result.reason).toMatch(/Athletics/i);
|
|
// German wording check — must contain a German keyword
|
|
expect(result.reason).toMatch(/(benötig|fehlt|Voraussetzung|mindestens)/);
|
|
}
|
|
});
|
|
|
|
it('returns ok:false when skill is missing entirely', () => {
|
|
const ctx = makeCtx({ skills: {} });
|
|
const result = evaluatePrereq('Trained in Athletics', ctx);
|
|
expect(isFail(result)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('evaluatePrereq — disjunctive (OR-list)', () => {
|
|
it('returns ok when any of the listed skills matches', () => {
|
|
const ctx = makeCtx({ skills: { Arcana: 'TRAINED' } });
|
|
expect(
|
|
evaluatePrereq('Trained in Arcana, Trained in Nature, or Trained in Religion', ctx),
|
|
).toEqual({ ok: true });
|
|
});
|
|
|
|
it('returns ok:false when no listed skill matches', () => {
|
|
const ctx = makeCtx({ skills: { Athletics: 'TRAINED' } });
|
|
const result = evaluatePrereq(
|
|
'Trained in Arcana, Trained in Nature, or Trained in Religion',
|
|
ctx,
|
|
);
|
|
expect(isFail(result)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('evaluatePrereq — conjunctive (semicolon AND)', () => {
|
|
it('returns ok when both clauses match', () => {
|
|
const ctx = makeCtx({ skills: { Deception: 'TRAINED', Stealth: 'TRAINED' } });
|
|
expect(evaluatePrereq('Trained in Deception; Trained in Stealth', ctx)).toEqual({
|
|
ok: true,
|
|
});
|
|
});
|
|
|
|
it('returns ok:false when one clause is missing', () => {
|
|
const ctx = makeCtx({ skills: { Deception: 'TRAINED' } });
|
|
const result = evaluatePrereq('Trained in Deception; Trained in Stealth', ctx);
|
|
expect(isFail(result)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('evaluatePrereq — bare feat name', () => {
|
|
it('returns ok when the feat is held', () => {
|
|
const ctx = makeCtx({ feats: new Set(['Power Attack']) });
|
|
expect(evaluatePrereq('Power Attack', ctx)).toEqual({ ok: true });
|
|
});
|
|
|
|
it('returns ok:false when the feat is missing', () => {
|
|
const ctx = makeCtx({ feats: new Set() });
|
|
const result = evaluatePrereq('Power Attack', ctx);
|
|
expect(isFail(result)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('evaluatePrereq — heritage', () => {
|
|
it('returns ok when heritage matches', () => {
|
|
const ctx = makeCtx({ heritageName: 'Unbreakable Goblin' });
|
|
expect(evaluatePrereq('Unbreakable Goblin heritage', ctx)).toEqual({ ok: true });
|
|
});
|
|
});
|
|
|
|
describe('evaluatePrereq — class ref', () => {
|
|
it('returns ok when class matches', () => {
|
|
const ctx = makeCtx({ className: 'Fighter' });
|
|
expect(evaluatePrereq('Fighter', ctx)).toEqual({ ok: true });
|
|
});
|
|
});
|
|
|
|
describe('evaluatePrereq — non-evaluable patterns (D-02 → unknown)', () => {
|
|
it('returns unknown for spellcasting refs', () => {
|
|
const result = evaluatePrereq('spellcasting class feature', makeCtx());
|
|
expect(result).toEqual({ unknown: true, raw: 'spellcasting class feature' });
|
|
});
|
|
|
|
it('returns unknown for deity refs', () => {
|
|
const result = evaluatePrereq('worshipper of Droskar', makeCtx());
|
|
expect('unknown' in result && result.unknown).toBe(true);
|
|
});
|
|
|
|
it('returns unknown for age refs', () => {
|
|
const result = evaluatePrereq('at least 100 years old', makeCtx());
|
|
expect('unknown' in result && result.unknown).toBe(true);
|
|
});
|
|
|
|
it('returns unknown for vision-trait refs', () => {
|
|
const result = evaluatePrereq('low-light vision', makeCtx());
|
|
expect('unknown' in result && result.unknown).toBe(true);
|
|
});
|
|
|
|
it('returns unknown for free-text patterns the parser cannot classify', () => {
|
|
const result = evaluatePrereq('You worship a god of fire and destruction', makeCtx());
|
|
expect('unknown' in result && result.unknown).toBe(true);
|
|
});
|
|
});
|