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:
parent
deca35a28f
commit
5820478f71
4 changed files with 263 additions and 0 deletions
25
plugins/voyage/tests/fixtures/playground/v43-export-bundle.json
vendored
Normal file
25
plugins/voyage/tests/fixtures/playground/v43-export-bundle.json
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"schema_version": 1,
|
||||
"exported_at": "2026-05-10T18:00:00Z",
|
||||
"target_artifact": "plan",
|
||||
"target_filename": "annotated-plan.md",
|
||||
"annotations": [
|
||||
{
|
||||
"id": "ANN-0001",
|
||||
"target_artifact": "plan",
|
||||
"target_anchor": "step-1-sentinel-touch",
|
||||
"intent": "question",
|
||||
"comment": "Should this sentinel use a deterministic timestamp?",
|
||||
"timestamp": "2026-05-10T18:01:00Z"
|
||||
},
|
||||
{
|
||||
"id": "ANN-0002",
|
||||
"target_artifact": "plan",
|
||||
"target_anchor": "step-2-sentinel-touch-paired",
|
||||
"intent": "fix",
|
||||
"comment": "Step 2 manifest should reference Step 1 in must_contain.",
|
||||
"timestamp": "2026-05-10T18:02:00Z"
|
||||
}
|
||||
],
|
||||
"annotation_digest": "PLACEHOLDER_OVERWRITTEN_AT_TEST_TIME"
|
||||
}
|
||||
69
plugins/voyage/tests/fixtures/playground/v43-plan-pre-annotate.md
vendored
Normal file
69
plugins/voyage/tests/fixtures/playground/v43-plan-pre-annotate.md
vendored
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
plan_version: 1.7
|
||||
profile: balanced
|
||||
revision: 0
|
||||
---
|
||||
|
||||
# v4.3 fixture — pre-annotate plan
|
||||
|
||||
Minimal plan used by Group C tests to seed an annotated round-trip.
|
||||
Two anchors target `Step 1` and `Step 2` so the export-bundle has at
|
||||
least 2 ANN-IDs to canonicalize for `annotation_digest`.
|
||||
|
||||
## Context
|
||||
|
||||
Fixture only — not executed. Anchors below match the v4.2 anchor format
|
||||
`<!-- voyage:anchor id="ANN-NNNN" target="<slug>" line="<N>" -->` and
|
||||
sit on their own line surrounded by blank lines (block-boundary rule).
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Sentinel touch
|
||||
|
||||
<!-- voyage:anchor id="ANN-0001" target="step-1-sentinel-touch" line="20" -->
|
||||
|
||||
- **Files:** `tmp/sentinel-1.txt` (new)
|
||||
- **Changes:** Create the sentinel file with the literal content "step-1".
|
||||
- **Reuses:** none.
|
||||
- **Test first:** none — sentinel-only step.
|
||||
- **Verify:** `test -f tmp/sentinel-1.txt`
|
||||
- **On failure:** revert.
|
||||
- **Checkpoint:** `git commit -m "chore: sentinel step 1"`
|
||||
- **Manifest:**
|
||||
```yaml
|
||||
manifest:
|
||||
expected_paths:
|
||||
- tmp/sentinel-1.txt
|
||||
min_file_count: 1
|
||||
commit_message_pattern: "^chore: sentinel step 1"
|
||||
bash_syntax_check: []
|
||||
forbidden_paths: []
|
||||
must_contain: []
|
||||
```
|
||||
|
||||
### Step 2: Sentinel touch (paired)
|
||||
|
||||
<!-- voyage:anchor id="ANN-0002" target="step-2-sentinel-touch-paired" line="38" -->
|
||||
|
||||
- **Files:** `tmp/sentinel-2.txt` (new)
|
||||
- **Changes:** Create the sentinel file with the literal content "step-2".
|
||||
- **Reuses:** none.
|
||||
- **Test first:** none.
|
||||
- **Verify:** `test -f tmp/sentinel-2.txt`
|
||||
- **On failure:** revert.
|
||||
- **Checkpoint:** `git commit -m "chore: sentinel step 2"`
|
||||
- **Manifest:**
|
||||
```yaml
|
||||
manifest:
|
||||
expected_paths:
|
||||
- tmp/sentinel-2.txt
|
||||
min_file_count: 1
|
||||
commit_message_pattern: "^chore: sentinel step 2"
|
||||
bash_syntax_check: []
|
||||
forbidden_paths: []
|
||||
must_contain: []
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
- Both sentinel files exist after execution.
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
// tests/playground/voyage-playground-structure.test.mjs
|
||||
// v4.3 Step 29 — Group B structural assertions for the voyage playground.
|
||||
//
|
||||
// Group B verifies that DS-token classes, theme-toggle wiring, and the
|
||||
// sidebar-tab/keyboard pattern are present in voyage-playground.html.
|
||||
// All assertions are static-grep (no DOM, no browser). Companion to:
|
||||
// - tests/playground/voyage-playground.test.mjs (Group A — SC1/3/6/7)
|
||||
// - tests/integration/annotation-export-schema.test.mjs (Group C — SC6)
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..', '..');
|
||||
const HTML = join(ROOT, 'playground', 'voyage-playground.html');
|
||||
|
||||
// --- DS-token classes present ----------------------------------------
|
||||
test('Group B — DS badge--scope-voyage class present (v4.3 Step 29)', () => {
|
||||
const text = readFileSync(HTML, 'utf-8');
|
||||
assert.match(text, /badge--scope-voyage/, 'badge--scope-voyage required');
|
||||
});
|
||||
|
||||
test('Group B — DS guide-panel + key-stats classes present (v4.3 Step 29)', () => {
|
||||
const text = readFileSync(HTML, 'utf-8');
|
||||
assert.match(text, /class="guide-panel/, 'guide-panel base class required');
|
||||
assert.match(text, /class="key-stats/, 'key-stats class required');
|
||||
});
|
||||
|
||||
test('Group B — DS fleet-grid + fleet-tile classes present (v4.3 Step 29)', () => {
|
||||
const text = readFileSync(HTML, 'utf-8');
|
||||
assert.match(text, /class="fleet-grid"/, 'fleet-grid required');
|
||||
assert.match(text, /class="fleet-tile/, 'fleet-tile required');
|
||||
});
|
||||
|
||||
// --- Theme-toggle wired ---------------------------------------------
|
||||
test('Group B — theme-toggle button has data-action attribute (v4.3 Step 29)', () => {
|
||||
const text = readFileSync(HTML, 'utf-8');
|
||||
assert.match(text, /data-action="toggle-theme"/, 'data-action=toggle-theme required');
|
||||
assert.match(text, /aria-label="Bytt tema"/, 'theme-toggle aria-label required');
|
||||
});
|
||||
|
||||
test('Group B — wireThemeToggle handler exists (v4.3 Step 29)', () => {
|
||||
const text = readFileSync(HTML, 'utf-8');
|
||||
assert.match(text, /function\s+wireThemeToggle\s*\(/, 'wireThemeToggle function required');
|
||||
});
|
||||
|
||||
test('Group B — theme persistence to localStorage (v4.3 Step 29)', () => {
|
||||
const text = readFileSync(HTML, 'utf-8');
|
||||
assert.match(text, /(voyage-theme|voyage_theme)/, 'theme localStorage key required');
|
||||
});
|
||||
|
||||
// --- Sidebar-tab / keyboard pattern ---------------------------------
|
||||
test('Group B — sidebar role=tablist with aria-selected (v4.3 Step 29)', () => {
|
||||
const text = readFileSync(HTML, 'utf-8');
|
||||
assert.match(text, /role="tablist"/, 'role=tablist required');
|
||||
assert.match(text, /aria-selected="(true|false)"/, 'aria-selected attribute required');
|
||||
});
|
||||
|
||||
test('Group B — keyboard nav J/K + Esc handlers wired (v4.3 Step 29)', () => {
|
||||
const text = readFileSync(HTML, 'utf-8');
|
||||
// Step 20 — J/K navigation + Esc dismiss
|
||||
assert.match(text, /(keydown|keypress|keyup)/, 'keyboard event listener required');
|
||||
assert.match(text, /(['"]j['"]|['"]J['"]|KeyJ)/, 'J navigation required');
|
||||
assert.match(text, /(['"]k['"]|['"]K['"]|KeyK)/, 'K navigation required');
|
||||
});
|
||||
|
||||
test('Group B — anchor-ID format ANN-NNNN matches Node-side parser (v4.3 Step 29)', () => {
|
||||
const text = readFileSync(HTML, 'utf-8');
|
||||
// Mirror of lib/parsers/anchor-parser.mjs ID_RE (^ANN-\d{4}$)
|
||||
assert.match(text, /\/\^ANN-\\d\{4\}\$\//, 'VOYAGE_ANCHOR_ID_RE pattern required');
|
||||
assert.match(text, /function\s+parseAnchor\s*\(/, 'parseAnchor function required');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue