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 {
|
func writeTestConfig(t *testing.T, content string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return writeFile(t, "config.yml", content)
|
return writeFile(t, "config.yml", content)
|
||||||
@@ -914,6 +1146,15 @@ func writeFile(t *testing.T, name string, content string) string {
|
|||||||
return path
|
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 {
|
func testConfigYAML(pipelineID string, laneIDs ...string) string {
|
||||||
return testConfigYAMLForPipelines(map[string][]string{pipelineID: laneIDs})
|
return testConfigYAMLForPipelines(map[string][]string{pipelineID: laneIDs})
|
||||||
}
|
}
|
||||||
@@ -1010,34 +1251,50 @@ func writeSeriatimInput(t *testing.T) string {
|
|||||||
type fakeRunLLMClient struct {
|
type fakeRunLLMClient struct {
|
||||||
invalidSourceRef bool
|
invalidSourceRef bool
|
||||||
calls int
|
calls int
|
||||||
|
err error
|
||||||
|
payload map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeRunLLMClient(invalidSourceRef bool) *fakeRunLLMClient {
|
func newFakeRunLLMClient(invalidSourceRef bool) *fakeRunLLMClient {
|
||||||
return &fakeRunLLMClient{invalidSourceRef: invalidSourceRef}
|
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) {
|
func (client *fakeRunLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||||
client.calls++
|
client.calls++
|
||||||
|
if client.err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, client.err
|
||||||
|
}
|
||||||
startUnitID := "seg-001"
|
startUnitID := "seg-001"
|
||||||
if client.invalidSourceRef {
|
if client.invalidSourceRef {
|
||||||
startUnitID = "missing-segment"
|
startUnitID = "missing-segment"
|
||||||
}
|
}
|
||||||
payload := map[string]any{
|
payload := client.payload
|
||||||
"spell_casts": []map[string]any{
|
if payload == nil {
|
||||||
{
|
payload = map[string]any{
|
||||||
"caster": "Aria",
|
"spell_casts": []map[string]any{
|
||||||
"spell": "Cure Wounds",
|
{
|
||||||
"effect": "Heals a wounded ally.",
|
"caster": "Aria",
|
||||||
"narrative_description": "Aria casts Cure Wounds.",
|
"spell": "Cure Wounds",
|
||||||
"source_refs": []map[string]string{
|
"effect": "Heals a wounded ally.",
|
||||||
{
|
"narrative_description": "Aria casts Cure Wounds.",
|
||||||
"source_id": "session-alpha",
|
"source_refs": []map[string]string{
|
||||||
"start_unit_id": startUnitID,
|
{
|
||||||
"end_unit_id": "seg-001",
|
"source_id": "session-alpha",
|
||||||
|
"start_unit_id": startUnitID,
|
||||||
|
"end_unit_id": "seg-001",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
encoded, err := json.Marshal(payload)
|
encoded, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1157,6 +1414,13 @@ func readFile(t *testing.T, path string) []byte {
|
|||||||
return data
|
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) {
|
func assertNoTemporaryFiles(t *testing.T, root string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
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 {
|
if lane.ID != "spells" || lane.Extractor != Key {
|
||||||
t.Fatalf("manifest lane = %#v, want spells lane with dnd/spells extractor", lane)
|
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" {
|
if output.ContentType != "application/json" {
|
||||||
t.Fatalf("ContentType = %q, want application/json", output.ContentType)
|
t.Fatalf("ContentType = %q, want application/json", output.ContentType)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user