Add fixture-driven MVP acceptance coverage
This commit is contained in:
16
examples/dnd-spells.config.yml
Normal file
16
examples/dnd-spells.config.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
version: 1
|
||||
llm_profiles:
|
||||
default:
|
||||
provider: openai-compatible
|
||||
base_url: http://127.0.0.1:1
|
||||
model: fake-model
|
||||
pipelines:
|
||||
dnd-session:
|
||||
input: seriatim
|
||||
chunk:
|
||||
module: generic
|
||||
options:
|
||||
max_units: 50
|
||||
artifacts:
|
||||
spells:
|
||||
extract: dnd/spells
|
||||
22
examples/seriatim-minimal-transcript.json
Normal file
22
examples/seriatim-minimal-transcript.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"metadata": {
|
||||
"id": "session-alpha",
|
||||
"title": "Synthetic D&D spell session"
|
||||
},
|
||||
"segments": [
|
||||
{
|
||||
"id": "seg-001",
|
||||
"start": 0,
|
||||
"end": 4,
|
||||
"speaker": "Aria",
|
||||
"text": "Aria raises her holy symbol and casts Cure Wounds."
|
||||
},
|
||||
{
|
||||
"id": "seg-002",
|
||||
"start": 4,
|
||||
"end": 8,
|
||||
"speaker": "DM",
|
||||
"text": "The bandit mage casts Shield as the blow lands."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -900,6 +900,238 @@ func TestRunPipelineDiagnosticsDirFlagOverridesConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExampleFixtureConfigValidateAndPipelinesList(t *testing.T) {
|
||||
configPath := fixturePath(t, "examples/dnd-spells.config.yml")
|
||||
|
||||
t.Run("validate configured pipeline", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "dnd-session"}, &stdout, &stderr, Options{})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "dnd-session") {
|
||||
t.Fatalf("stdout = %q, want pipeline ID", stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list configured pipelines", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := RunWithOptions([]string{"pipelines", "list", "--config", configPath}, &stdout, &stderr, Options{})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
||||
}
|
||||
if got, want := stdout.String(), "dnd-session\n"; got != want {
|
||||
t.Fatalf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExampleFixtureRunWritesExpectedJSON(t *testing.T) {
|
||||
configPath := fixturePath(t, "examples/dnd-spells.config.yml")
|
||||
inputPath := fixturePath(t, "examples/seriatim-minimal-transcript.json")
|
||||
outputDir := t.TempDir()
|
||||
diagnosticsDir := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--output-dir", outputDir, "--diagnostics-dir", diagnosticsDir}, &stdout, &stderr, Options{
|
||||
LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil),
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
||||
}
|
||||
runOutputDir := onlyChildDir(t, outputDir)
|
||||
if !strings.Contains(stdout.String(), runOutputDir) {
|
||||
t.Fatalf("stdout = %q, want output path %q", stdout.String(), runOutputDir)
|
||||
}
|
||||
|
||||
var manifest artifacts.RunManifest
|
||||
readJSONFile(t, filepath.Join(runOutputDir, "manifest.json"), &manifest)
|
||||
if manifest.PipelineID != "dnd-session" {
|
||||
t.Fatalf("manifest pipeline ID = %q, want dnd-session", manifest.PipelineID)
|
||||
}
|
||||
if manifest.PipelineDigest == "" {
|
||||
t.Fatal("manifest pipeline digest is empty")
|
||||
}
|
||||
if manifest.ValidationStatus != "approved" {
|
||||
t.Fatalf("validation status = %q, want approved", manifest.ValidationStatus)
|
||||
}
|
||||
if len(manifest.ArtifactLanes) != 1 {
|
||||
t.Fatalf("artifact lanes = %#v, want one lane", manifest.ArtifactLanes)
|
||||
}
|
||||
extractorMetadata, ok := manifest.ArtifactLanes[0].Metadata["extractor"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("extractor metadata = %#v, want object", manifest.ArtifactLanes[0].Metadata)
|
||||
}
|
||||
if extractorMetadata["prompt_id"] != "dnd.spells" || extractorMetadata["response_schema_key"] != "dnd_spells" {
|
||||
t.Fatalf("extractor metadata = %#v, want prompt/schema identifiers", extractorMetadata)
|
||||
}
|
||||
|
||||
var artifactFile struct {
|
||||
ArtifactType string `json:"artifact_type"`
|
||||
Artifacts []artifacts.Artifact `json:"artifacts"`
|
||||
}
|
||||
readJSONFile(t, filepath.Join(runOutputDir, "artifacts", "dnd.spell_cast.json"), &artifactFile)
|
||||
if artifactFile.ArtifactType != "dnd.spell_cast" || len(artifactFile.Artifacts) != 1 {
|
||||
t.Fatalf("artifact file = %#v, want one spell artifact", artifactFile)
|
||||
}
|
||||
var payload struct {
|
||||
Caster string `json:"caster"`
|
||||
Spell string `json:"spell"`
|
||||
Effect string `json:"effect"`
|
||||
}
|
||||
if err := json.Unmarshal(artifactFile.Artifacts[0].Payload, &payload); err != nil {
|
||||
t.Fatalf("unmarshal spell payload: %v", err)
|
||||
}
|
||||
if payload.Caster != "Aria" || payload.Spell != "Cure Wounds" || payload.Effect == "" {
|
||||
t.Fatalf("payload = %#v, want deterministic spell output", payload)
|
||||
}
|
||||
if len(artifactFile.Artifacts[0].SourceRefs) != 1 {
|
||||
t.Fatalf("source refs = %#v, want one source ref", artifactFile.Artifacts[0].SourceRefs)
|
||||
}
|
||||
ref := artifactFile.Artifacts[0].SourceRefs[0]
|
||||
if ref.SourceID != "session-alpha" || ref.StartUnitID != "seg-001" || ref.EndUnitID != "seg-001" {
|
||||
t.Fatalf("source ref = %#v, want fixture source ref", ref)
|
||||
}
|
||||
|
||||
warnings := string(readFile(t, filepath.Join(runOutputDir, "warnings.json")))
|
||||
if !strings.Contains(warnings, `"warnings": []`) {
|
||||
t.Fatalf("warnings output = %s, want empty warnings", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExampleFixtureRunOnlySpells(t *testing.T) {
|
||||
configPath := fixturePath(t, "examples/dnd-spells.config.yml")
|
||||
inputPath := fixturePath(t, "examples/seriatim-minimal-transcript.json")
|
||||
outputDir := t.TempDir()
|
||||
diagnosticsDir := t.TempDir()
|
||||
client := newFakeRunLLMClient(false)
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--only", "spells", "--output-dir", outputDir, "--diagnostics-dir", diagnosticsDir}, &stdout, &stderr, Options{
|
||||
LLMClientFactory: fakeLLMFactory(client, nil),
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
||||
}
|
||||
if client.calls != 1 {
|
||||
t.Fatalf("LLM calls = %d, want selected lane once", client.calls)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(onlyChildDir(t, outputDir), "manifest.json")); err != nil {
|
||||
t.Fatalf("expected manifest output: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExampleFixtureFailureCoverage(t *testing.T) {
|
||||
configPath := fixturePath(t, "examples/dnd-spells.config.yml")
|
||||
inputPath := fixturePath(t, "examples/seriatim-minimal-transcript.json")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
factory LLMClientFactory
|
||||
wantCode int
|
||||
wantStderr string
|
||||
wantOutputStatus string
|
||||
}{
|
||||
{
|
||||
name: "missing config",
|
||||
args: []string{"config", "validate", "--config", filepath.Join(filepath.Dir(configPath), "missing.yml"), "--pipeline", "dnd-session"},
|
||||
wantCode: 1,
|
||||
wantStderr: "config file",
|
||||
},
|
||||
{
|
||||
name: "unknown pipeline",
|
||||
args: []string{"run", "missing", "--config", configPath, "--input", inputPath},
|
||||
factory: fakeLLMFactory(newFakeRunLLMClient(false), nil),
|
||||
wantCode: 1,
|
||||
wantStderr: "not configured",
|
||||
},
|
||||
{
|
||||
name: "invalid Seriatim input",
|
||||
args: []string{"run", "dnd-session", "--config", configPath, "--input", fixturePath(t, "internal/cli/testdata/invalid-seriatim-empty-segments.json")},
|
||||
factory: fakeLLMFactory(newFakeRunLLMClient(false), nil),
|
||||
wantCode: 1,
|
||||
wantStderr: "segments must not be empty",
|
||||
},
|
||||
{
|
||||
name: "invalid only lane",
|
||||
args: []string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--only", "missing"},
|
||||
factory: fakeLLMFactory(newFakeRunLLMClient(false), nil),
|
||||
wantCode: 1,
|
||||
wantStderr: "selected artifact lane",
|
||||
},
|
||||
{
|
||||
name: "fake LLM failure",
|
||||
args: []string{"run", "dnd-session", "--config", configPath, "--input", inputPath},
|
||||
factory: fakeLLMFactory(newErrorRunLLMClient(errors.New("completion unavailable")), nil),
|
||||
wantCode: 1,
|
||||
wantStderr: "completion unavailable",
|
||||
},
|
||||
{
|
||||
name: "malformed LLM response",
|
||||
args: []string{"run", "dnd-session", "--config", configPath, "--input", inputPath},
|
||||
factory: fakeLLMFactory(newMalformedRunLLMClient(), nil),
|
||||
wantCode: 1,
|
||||
wantStderr: "spell_casts",
|
||||
},
|
||||
{
|
||||
name: "invalid source reference rejection",
|
||||
args: []string{"run", "dnd-session", "--config", configPath, "--input", inputPath},
|
||||
factory: fakeLLMFactory(newFakeRunLLMClient(true), nil),
|
||||
wantCode: 0,
|
||||
wantOutputStatus: "rejected",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
outputDir := t.TempDir()
|
||||
diagnosticsDir := t.TempDir()
|
||||
args := append([]string(nil), test.args...)
|
||||
if args[0] == "run" {
|
||||
args = append(args, "--output-dir", outputDir, "--diagnostics-dir", diagnosticsDir)
|
||||
}
|
||||
factory := test.factory
|
||||
if factory == nil {
|
||||
factory = fakeLLMFactory(newFakeRunLLMClient(false), nil)
|
||||
}
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := RunWithOptions(args, &stdout, &stderr, Options{LLMClientFactory: factory})
|
||||
|
||||
if code != test.wantCode {
|
||||
t.Fatalf("RunWithOptions() code = %d, want %d; stderr=%q", code, test.wantCode, stderr.String())
|
||||
}
|
||||
if test.wantStderr != "" && !strings.Contains(stderr.String(), test.wantStderr) {
|
||||
t.Fatalf("stderr = %q, want substring %q", stderr.String(), test.wantStderr)
|
||||
}
|
||||
if test.wantOutputStatus != "" {
|
||||
runOutputDir := onlyChildDir(t, outputDir)
|
||||
var manifest artifacts.RunManifest
|
||||
readJSONFile(t, filepath.Join(runOutputDir, "manifest.json"), &manifest)
|
||||
if manifest.ValidationStatus != test.wantOutputStatus {
|
||||
t.Fatalf("validation status = %q, want %q", manifest.ValidationStatus, test.wantOutputStatus)
|
||||
}
|
||||
rejected := string(readFile(t, filepath.Join(runOutputDir, "rejected.json")))
|
||||
if !strings.Contains(rejected, "invalid_source_ref") {
|
||||
t.Fatalf("rejected output = %s, want invalid source ref rejection", rejected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
return writeFile(t, "config.yml", content)
|
||||
@@ -914,6 +1146,15 @@ func writeFile(t *testing.T, name string, content string) string {
|
||||
return path
|
||||
}
|
||||
|
||||
func fixturePath(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join("..", "..", filepath.FromSlash(name))
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("fixture %q is not available at %q: %v", name, path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func testConfigYAML(pipelineID string, laneIDs ...string) string {
|
||||
return testConfigYAMLForPipelines(map[string][]string{pipelineID: laneIDs})
|
||||
}
|
||||
@@ -1010,34 +1251,50 @@ func writeSeriatimInput(t *testing.T) string {
|
||||
type fakeRunLLMClient struct {
|
||||
invalidSourceRef bool
|
||||
calls int
|
||||
err error
|
||||
payload map[string]any
|
||||
}
|
||||
|
||||
func newFakeRunLLMClient(invalidSourceRef bool) *fakeRunLLMClient {
|
||||
return &fakeRunLLMClient{invalidSourceRef: invalidSourceRef}
|
||||
}
|
||||
|
||||
func newErrorRunLLMClient(err error) *fakeRunLLMClient {
|
||||
return &fakeRunLLMClient{err: err}
|
||||
}
|
||||
|
||||
func newMalformedRunLLMClient() *fakeRunLLMClient {
|
||||
return &fakeRunLLMClient{payload: map[string]any{}}
|
||||
}
|
||||
|
||||
func (client *fakeRunLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||
client.calls++
|
||||
if client.err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, client.err
|
||||
}
|
||||
startUnitID := "seg-001"
|
||||
if client.invalidSourceRef {
|
||||
startUnitID = "missing-segment"
|
||||
}
|
||||
payload := map[string]any{
|
||||
"spell_casts": []map[string]any{
|
||||
{
|
||||
"caster": "Aria",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "Heals a wounded ally.",
|
||||
"narrative_description": "Aria casts Cure Wounds.",
|
||||
"source_refs": []map[string]string{
|
||||
{
|
||||
"source_id": "session-alpha",
|
||||
"start_unit_id": startUnitID,
|
||||
"end_unit_id": "seg-001",
|
||||
payload := client.payload
|
||||
if payload == nil {
|
||||
payload = map[string]any{
|
||||
"spell_casts": []map[string]any{
|
||||
{
|
||||
"caster": "Aria",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "Heals a wounded ally.",
|
||||
"narrative_description": "Aria casts Cure Wounds.",
|
||||
"source_refs": []map[string]string{
|
||||
{
|
||||
"source_id": "session-alpha",
|
||||
"start_unit_id": startUnitID,
|
||||
"end_unit_id": "seg-001",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@@ -1157,6 +1414,13 @@ func readFile(t *testing.T, path string) []byte {
|
||||
return data
|
||||
}
|
||||
|
||||
func readJSONFile(t *testing.T, path string, out any) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal(readFile(t, path), out); err != nil {
|
||||
t.Fatalf("unmarshal %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoTemporaryFiles(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
if err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
||||
|
||||
6
internal/cli/testdata/invalid-seriatim-empty-segments.json
vendored
Normal file
6
internal/cli/testdata/invalid-seriatim-empty-segments.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"metadata": {
|
||||
"id": "session-alpha"
|
||||
},
|
||||
"segments": []
|
||||
}
|
||||
@@ -93,6 +93,15 @@ func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) {
|
||||
if lane.ID != "spells" || lane.Extractor != Key {
|
||||
t.Fatalf("manifest lane = %#v, want spells lane with dnd/spells extractor", lane)
|
||||
}
|
||||
extractorMetadata, ok := lane.Metadata["extractor"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("manifest lane metadata = %#v, want extractor metadata", lane.Metadata)
|
||||
}
|
||||
if extractorMetadata["prompt_id"] != PromptID ||
|
||||
extractorMetadata["response_schema_key"] != string(ResponseSchemaKey) ||
|
||||
extractorMetadata["response_schema_name"] != ResponseSchemaName {
|
||||
t.Fatalf("extractor metadata = %#v, want prompt/schema identifiers", extractorMetadata)
|
||||
}
|
||||
if output.ContentType != "application/json" {
|
||||
t.Fatalf("ContentType = %q, want application/json", output.ContentType)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user