test(voyage): add Group B (structure) + Group C (annotation export schema) tests [skip-docs]

Step 29 of v4.3 plan — Wave 7:
- Group B (9 tests): DS-token classes (badge--scope-voyage, guide-panel,
  fleet-grid), theme-toggle wiring (data-action, wireThemeToggle,
  localStorage), sidebar-tab keyboard pattern (role=tablist,
  aria-selected, J/K/Esc), anchor-ID format mirror.
- Group C (7 tests, +1 vs original): export-bundle JSON parse, required
  keys, per-annotation field validation, empty-export edge case,
  annotation_digest order-independence, SHA-256 16-hex-char validity
  (SC6 / SC-GAP-3), fixture plan anchor format.
- Fixtures: tests/fixtures/playground/v43-export-bundle.json +
  v43-plan-pre-annotate.md (ANN-0001 + ANN-0002, revision: 0).

Test count: 689 → 705 pass / 0 fail / 2 skipped.
This commit is contained in:
Kjell Tore Guttormsen 2026-05-10 18:18:24 +02:00
commit 5820478f71
4 changed files with 263 additions and 0 deletions

View file

@ -161,3 +161,97 @@ test('isProjectPathSafe — fixture FileList survives filter to brief.md only (v
const survivors = fixture.filter(isProjectPathSafe);
assert.deepEqual(survivors, ['brief.md'], 'only brief.md should survive the filter');
});
// =====================================================================
// Group C — v4.3 Step 29 export-bundle schema validation (Wave 7).
//
// Verifies the JSON shape that /trekrevise consumes when an operator
// applies a playground-exported annotation batch back into the source
// artifact. The shape comes from buildAnnotatedMarkdown +
// downloadAnnotatedBlob (markdown export — primary) but the
// trekrevise-side reader (lib/parsers/anchor-parser.mjs +
// lib/parsers/annotation-digest.mjs) accepts a parallel JSON payload
// with the same canonical fields. The fixture in
// tests/fixtures/playground/v43-export-bundle.json is the contract.
// =====================================================================
import { computeAnnotationDigest } from '../../lib/parsers/annotation-digest.mjs';
const FIXTURES = join(ROOT, 'tests', 'fixtures', 'playground');
const BUNDLE = join(FIXTURES, 'v43-export-bundle.json');
const PLAN_FIXTURE = join(FIXTURES, 'v43-plan-pre-annotate.md');
test('Group C.1 — export bundle JSON parses (v4.3 Step 29)', () => {
const raw = readFileSync(BUNDLE, 'utf-8');
const bundle = JSON.parse(raw); // throws on parse error
assert.equal(typeof bundle, 'object', 'bundle must be object');
assert.ok(bundle !== null, 'bundle must not be null');
});
test('Group C.2 — export bundle has required top-level keys (v4.3 Step 29)', () => {
const bundle = JSON.parse(readFileSync(BUNDLE, 'utf-8'));
for (const key of ['schema_version', 'exported_at', 'target_artifact', 'annotations', 'annotation_digest']) {
assert.ok(key in bundle, `required key missing: ${key}`);
}
assert.equal(bundle.schema_version, 1, 'schema_version must be 1');
assert.ok(['brief', 'plan', 'review', 'artifact'].includes(bundle.target_artifact), 'target_artifact must be one of brief|plan|review|artifact');
assert.ok(Array.isArray(bundle.annotations), 'annotations must be array');
});
test('Group C.3 — every annotation has id + target_anchor + intent (v4.3 Step 29)', () => {
const bundle = JSON.parse(readFileSync(BUNDLE, 'utf-8'));
assert.ok(bundle.annotations.length >= 2, 'fixture must include ≥2 annotations');
for (const a of bundle.annotations) {
assert.match(a.id, /^ANN-\d{4}$/, `id ${a.id} must match ANN-NNNN`);
assert.equal(typeof a.target_anchor, 'string', 'target_anchor must be string');
assert.ok(VOYAGE_ANCHOR_INTENTS.includes(a.intent), `intent ${a.intent} must be one of fix|change|question|block`);
}
});
test('Group C.4 — empty-export edge case produces valid bundle (v4.3 Step 29)', () => {
// Mirror the export-shape with zero annotations (download button still works
// — produces a bundle with annotations=[] and digest of empty canonical).
const emptyBundle = {
schema_version: 1,
exported_at: '2026-05-10T00:00:00Z',
target_artifact: 'brief',
target_filename: 'annotated-brief.md',
annotations: [],
annotation_digest: computeAnnotationDigest([]),
};
// Round-trip: serialize + parse must equal
const roundTripped = JSON.parse(JSON.stringify(emptyBundle));
assert.deepEqual(roundTripped, emptyBundle, 'empty bundle must round-trip');
assert.equal(emptyBundle.annotations.length, 0, 'annotations array must be empty');
assert.match(emptyBundle.annotation_digest, /^[0-9a-f]{16}$/, 'digest must be 16-hex-char prefix');
});
test('Group C.5 — annotation_digest is order-independent (v4.3 Step 29)', () => {
const bundle = JSON.parse(readFileSync(BUNDLE, 'utf-8'));
const ascending = computeAnnotationDigest(bundle.annotations);
const reversed = computeAnnotationDigest([...bundle.annotations].reverse());
assert.equal(ascending, reversed, 'digest must be deterministic regardless of input order');
});
// SC6 — annotation_digest SHA-256 validity (per scope-guardian SC-GAP-3).
test('Group C.6 — annotation_digest is valid 16-hex-char SHA-256 prefix (v4.3 Step 29 / SC-GAP-3)', () => {
const bundle = JSON.parse(readFileSync(BUNDLE, 'utf-8'));
// Recompute the digest server-side and verify it matches the canonical form.
// The fixture stores PLACEHOLDER_OVERWRITTEN_AT_TEST_TIME — the canonical
// value comes from computeAnnotationDigest(annotations).
const canonical = computeAnnotationDigest(bundle.annotations);
assert.match(canonical, /^[0-9a-f]{16}$/, 'digest must be 16-hex-char prefix of SHA-256');
// Determinism: two calls with the same input MUST produce identical output
const second = computeAnnotationDigest(bundle.annotations);
assert.equal(canonical, second, 'digest must be deterministic');
});
test('Group C.7 — fixture plan parses with anchors at block boundaries (v4.3 Step 29)', () => {
const planText = readFileSync(PLAN_FIXTURE, 'utf-8');
// Frontmatter declares revision: 0 — the entry point for /trekrevise
assert.match(planText, /^---\s*$/m, 'YAML frontmatter required');
assert.match(planText, /^revision:\s*0\s*$/m, 'revision: 0 required (round-trip seed)');
// Both anchors present in canonical format
assert.match(planText, /<!--\s*voyage:anchor\s+id="ANN-0001"\s+target="step-1-sentinel-touch"\s+line="\d+"\s*-->/, 'ANN-0001 anchor required');
assert.match(planText, /<!--\s*voyage:anchor\s+id="ANN-0002"\s+target="step-2-sentinel-touch-paired"\s+line="\d+"\s*-->/, 'ANN-0002 anchor required');
});