ktg-plugin-marketplace/plugins/voyage/tests/validators/profile-validator.test.mjs
Kjell Tore Guttormsen be9ad6ec07 feat(voyage): add lib/validators/profile-validator.mjs — SC #1, #2, #3
Step 5 av v4.1-execute (Wave 2, Session 2).

Profile-validator etter brief-validator-mønsteret eksakt: validateProfileContent
(pure), validateProfile (file-reader), CLI shim med --json flag. Eksporter
PROFILE_REQUIRED_FIELDS (frozen), PROFILE_REQUIRED_PHASES (frozen).

Validerer:
- Required frontmatter fields (name, phase_models, parallel_agents_min/max,
  external_research_enabled, brief_reviewer_iter_cap)
- phase_models = list-of-dicts med phase + model
- 6 required phases (brief, research, plan, execute, review, continue)
- parallel_agents_max ≥ parallel_agents_min
- Allowed model values: ['sonnet', 'opus']; haiku tillatt KUN ved
  VOYAGE_ALLOW_HAIKU=1 (per global CLAUDE.md modellvalg-prinsipp)

Issue codes: PROFILE_MISSING_FIELD, PROFILE_INVALID_MODEL, PROFILE_INVALID_ENUM,
PROFILE_READ_ERROR, PROFILE_NOT_FOUND.

Field-path-reporting i error-location: phase_models[N].model for SC #2.

Tester (10 nye, baseline 377 → 387):
- SC #1 x3 (innebygde profiler grønne)
- SC #2 (PROFILE_INVALID_MODEL med location phase_models[2].model)
- SC #3 (PROFILE_INVALID_ENUM for external_research_enabled: "yes" string)
- VOYAGE_ALLOW_HAIKU env-var deny/allow
- PROFILE_MISSING_FIELD når name fraværende
- PROFILE_NOT_FOUND for ikke-eksisterende fil
- 2 export drift-pins

Fixturer: profile-invalid-model.yaml (gpt-4 i phase_models[2]),
profile-invalid-enum.yaml (external_research_enabled som string "yes").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 09:26:23 +02:00

150 lines
5.3 KiB
JavaScript

// tests/validators/profile-validator.test.mjs
// SC #1, #2, #3: profile-validator validates lib/profiles/{economy,balanced,premium}.yaml
// (innebygde profiler) plus rejects invalid models and invalid enum types.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import {
validateProfile,
validateProfileContent,
PROFILE_REQUIRED_FIELDS,
PROFILE_REQUIRED_PHASES,
} from '../../lib/validators/profile-validator.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, '..', '..');
// SC #1: alle 3 innebygde profiler grønne
for (const profileName of ['economy', 'balanced', 'premium']) {
test(`SC #1: lib/profiles/${profileName}.yaml validates clean`, () => {
const r = validateProfile(join(REPO_ROOT, 'lib', 'profiles', `${profileName}.yaml`));
assert.equal(r.valid, true,
`expected valid, got errors: ${JSON.stringify(r.errors, null, 2)}`);
assert.equal(r.errors.length, 0);
// Spot-check: all 6 phases present
const phases = r.parsed.frontmatter.phase_models.map(p => p.phase);
for (const required of PROFILE_REQUIRED_PHASES) {
assert.ok(phases.includes(required), `${profileName} missing phase: ${required}`);
}
});
}
// SC #2: PROFILE_INVALID_MODEL fired when phase_models[N].model not in allowlist
test('SC #2: profile-invalid-model.yaml rejected with PROFILE_INVALID_MODEL at phase_models[2].model', () => {
const r = validateProfile(join(REPO_ROOT, 'tests', 'fixtures', 'profile-invalid-model.yaml'));
assert.equal(r.valid, false);
const found = r.errors.find(e => e.code === 'PROFILE_INVALID_MODEL');
assert.ok(found, `expected PROFILE_INVALID_MODEL, got: ${JSON.stringify(r.errors)}`);
assert.equal(found.location, 'phase_models[2].model',
`expected location phase_models[2].model, got ${found.location}`);
assert.match(found.message, /gpt-4/);
});
// SC #3: PROFILE_INVALID_ENUM for wrong-type values
test('SC #3: profile-invalid-enum.yaml rejected with PROFILE_INVALID_ENUM (external_research_enabled is string)', () => {
const r = validateProfile(join(REPO_ROOT, 'tests', 'fixtures', 'profile-invalid-enum.yaml'));
assert.equal(r.valid, false);
const found = r.errors.find(e => e.code === 'PROFILE_INVALID_ENUM' && /external_research_enabled/.test(e.message));
assert.ok(found, `expected PROFILE_INVALID_ENUM for external_research_enabled, got: ${JSON.stringify(r.errors)}`);
assert.match(found.message, /boolean/);
});
// VOYAGE_ALLOW_HAIKU env-var allows haiku model
test('VOYAGE_ALLOW_HAIKU=1 allows haiku in phase_models', () => {
const haikuProfile = `---
profile_version: "1.0"
name: haiku-allowed
phase_models:
- phase: brief
model: haiku
- phase: research
model: sonnet
- phase: plan
model: opus
- phase: execute
model: sonnet
- phase: review
model: opus
- phase: continue
model: sonnet
parallel_agents_min: 2
parallel_agents_max: 4
external_research_enabled: false
brief_reviewer_iter_cap: 1
---
`;
// Default env: haiku rejected
const denied = validateProfileContent(haikuProfile, { env: { /* no VOYAGE_ALLOW_HAIKU */ } });
assert.equal(denied.valid, false);
const haikuErr = denied.errors.find(e => e.code === 'PROFILE_INVALID_MODEL' && /haiku/.test(e.message));
assert.ok(haikuErr, `expected haiku rejection: ${JSON.stringify(denied.errors)}`);
assert.match(haikuErr.message, /VOYAGE_ALLOW_HAIKU/);
// With opt-in: haiku accepted
const allowed = validateProfileContent(haikuProfile, { env: { VOYAGE_ALLOW_HAIKU: '1' } });
assert.equal(allowed.valid, true,
`expected valid with VOYAGE_ALLOW_HAIKU=1, got: ${JSON.stringify(allowed.errors)}`);
});
// Required fields presence
test('PROFILE_MISSING_FIELD when name absent', () => {
const r = validateProfileContent(`---
profile_version: "1.0"
phase_models:
- phase: brief
model: sonnet
- phase: research
model: sonnet
- phase: plan
model: opus
- phase: execute
model: sonnet
- phase: review
model: opus
- phase: continue
model: sonnet
parallel_agents_min: 2
parallel_agents_max: 4
external_research_enabled: false
brief_reviewer_iter_cap: 1
---
`);
assert.equal(r.valid, false);
const found = r.errors.find(e => e.code === 'PROFILE_MISSING_FIELD' && /name/.test(e.message));
assert.ok(found, `expected PROFILE_MISSING_FIELD for name, got: ${JSON.stringify(r.errors)}`);
});
// PROFILE_NOT_FOUND for missing file
test('PROFILE_NOT_FOUND for non-existent file', () => {
const r = validateProfile('/tmp/does-not-exist-profile-xyz.yaml');
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PROFILE_NOT_FOUND'));
});
// REQUIRED_FIELDS frontmatter contract drift-pin
test('PROFILE_REQUIRED_FIELDS export drift-pin', () => {
assert.deepEqual(
[...PROFILE_REQUIRED_FIELDS].sort(),
['brief_reviewer_iter_cap', 'external_research_enabled', 'name',
'parallel_agents_max', 'parallel_agents_min', 'phase_models'].sort(),
'PROFILE_REQUIRED_FIELDS contract drift — pin contract',
);
});
test('PROFILE_REQUIRED_PHASES export drift-pin', () => {
assert.deepEqual(
[...PROFILE_REQUIRED_PHASES].sort(),
['brief', 'research', 'plan', 'execute', 'review', 'continue'].sort(),
'PROFILE_REQUIRED_PHASES contract drift — pin contract',
);
});