762 lines
31 KiB
Go
762 lines
31 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
|
)
|
|
|
|
func TestResolvePipelineValidatesModuleAndValidatorOptions(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*PipelineProfile)
|
|
want []string
|
|
}{
|
|
{
|
|
name: "module",
|
|
mutate: func(profile *PipelineProfile) {
|
|
profile.Input.Options = map[string]any{"surprise": true}
|
|
},
|
|
want: []string{`pipeline "construction" input module "input" options`, `unknown option "surprise"`},
|
|
},
|
|
{
|
|
name: "validator",
|
|
mutate: func(profile *PipelineProfile) {
|
|
profile.Chunk.Validators = ValidatorOverride{Set: true, Validators: []ModuleBinding{{Module: "configured", Options: map[string]any{"surprise": true}}}}
|
|
},
|
|
want: []string{`pipeline "construction" chunk module "chunk" validator "configured" options`, `unknown option "surprise"`},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
registries, _ := constructionRegistries(t, nil, nil)
|
|
profile := constructionProfile()
|
|
test.mutate(&profile)
|
|
_, err := ResolvePipeline(profile, ResolveOptions{}, registries.catalog())
|
|
if err == nil {
|
|
t.Fatal("ResolvePipeline() error = nil, want option validation error")
|
|
}
|
|
for _, want := range test.want {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("ResolvePipeline() error = %q, want substring %q", err, want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPrepareConstructsEverythingInStableOrder(t *testing.T) {
|
|
var built []string
|
|
registries, _ := constructionRegistries(t, &built, nil)
|
|
resolved, err := ResolvePipeline(constructionProfile(), ResolveOptions{}, registries.catalog())
|
|
if err != nil {
|
|
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
|
|
}
|
|
|
|
prepared, err := Prepare(resolved, registries, ModuleDependencies{})
|
|
if err != nil {
|
|
t.Fatalf("Prepare() error = %v, want nil", err)
|
|
}
|
|
want := []string{"input", "chunk", "validator", "extract", "validator", "merge", "validator", "normalize", "validator", "output"}
|
|
if !reflect.DeepEqual(built, want) {
|
|
t.Fatalf("construction order = %#v, want %#v", built, want)
|
|
}
|
|
if prepared.Input.Module != "input" || prepared.Chunk.Module != "chunk" || prepared.Output.Module != "output" || len(prepared.Steps) != 1 || len(prepared.Steps[0].ArtifactLanes) != 1 {
|
|
t.Fatalf("PreparedPipeline = %#v, want explicit resolved components", prepared)
|
|
}
|
|
}
|
|
|
|
func TestPrepareBuildsEveryOrderedStepBeforeExecution(t *testing.T) {
|
|
var built []string
|
|
registries, input := constructionRegistries(t, &built, nil)
|
|
profile := constructionProfile()
|
|
lane := profile.Artifacts["artifact"]
|
|
profile.Artifacts = nil
|
|
profile.Steps = []PipelineStepProfile{
|
|
{ID: "first", Artifacts: map[string]ArtifactLaneProfile{"first-artifact": lane}},
|
|
{ID: "second", Artifacts: map[string]ArtifactLaneProfile{"second-artifact": lane}},
|
|
}
|
|
resolved, err := ResolvePipeline(profile, ResolveOptions{}, registries.catalog())
|
|
if err != nil {
|
|
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
|
|
}
|
|
prepared, err := Prepare(resolved, registries, ModuleDependencies{})
|
|
if err != nil {
|
|
t.Fatalf("Prepare() error = %v, want nil", err)
|
|
}
|
|
want := []string{
|
|
"input", "chunk", "validator",
|
|
"extract", "validator", "merge", "validator", "normalize", "validator",
|
|
"extract", "validator", "merge", "validator", "normalize", "validator",
|
|
"output",
|
|
}
|
|
if !reflect.DeepEqual(built, want) {
|
|
t.Fatalf("construction order = %#v, want %#v", built, want)
|
|
}
|
|
if len(prepared.Steps) != 2 || len(prepared.Steps[0].lanes) != 1 || len(prepared.Steps[1].lanes) != 1 {
|
|
t.Fatalf("prepared steps = %#v, want two constructed steps", prepared.Steps)
|
|
}
|
|
if len(input.requests) != 0 {
|
|
t.Fatalf("input Parse calls = %d, want zero during preparation", len(input.requests))
|
|
}
|
|
}
|
|
|
|
func TestPrepareRetainsGeneratedSelectorsWithoutReferenceBytes(t *testing.T) {
|
|
var built []string
|
|
registries, _ := constructionRegistries(t, &built, nil)
|
|
resolved, err := ResolvePipeline(constructionProfile(), ResolveOptions{}, registries.catalog())
|
|
if err != nil {
|
|
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
|
|
}
|
|
resolved.Steps[0].ArtifactLanes[0].ExtractReferences.Bindings = []ReferenceBinding{{
|
|
Stage: StageExtract,
|
|
LaneID: "artifact",
|
|
SlotName: "generated",
|
|
Artifact: &ArtifactReference{Step: "producer", Lane: "artifact"},
|
|
}}
|
|
prepared, err := Prepare(resolved, registries, ModuleDependencies{})
|
|
if err != nil {
|
|
t.Fatalf("Prepare() error = %v, want nil", err)
|
|
}
|
|
if len(prepared.Steps[0].ArtifactLanes[0].Resolved.ExtractReferences.ReferenceSet.Slots) != 0 {
|
|
t.Fatalf("prepared generated reference set = %#v, want no reference bytes", prepared.Steps[0].ArtifactLanes[0].Resolved.ExtractReferences.ReferenceSet)
|
|
}
|
|
if len(built) == 0 {
|
|
t.Fatal("constructed modules = empty, want preparation to construct the selected components")
|
|
}
|
|
}
|
|
|
|
func TestPreparedCheckpointFingerprintsCollectEveryComponentInStableScopeOrder(t *testing.T) {
|
|
provider := func(value string) checkpointFingerprintTestProvider {
|
|
return checkpointFingerprintTestProvider{{Name: "identity", Value: value}}
|
|
}
|
|
prepared := &PreparedPipeline{
|
|
resolved: ResolvedPipeline{
|
|
ID: "fingerprints",
|
|
Input: Binding("input"),
|
|
Chunk: Binding("chunk"),
|
|
Output: Binding("output"),
|
|
},
|
|
input: provider("input"),
|
|
chunker: provider("chunk"),
|
|
chunkValidators: preparedValidatorChain{validators: []preparedValidator{{
|
|
resolved: ResolvedValidator{Binding: Binding("chunk-validator"), Target: ValidatorTargetChunk},
|
|
chunk: provider("chunk-validator"),
|
|
}}},
|
|
output: provider("output"),
|
|
Steps: []PreparedPipelineStep{{ID: "default"}},
|
|
}
|
|
lane := preparedLaneExecutor{
|
|
resolved: ResolvedArtifactLane{
|
|
ID: "spells",
|
|
Extract: Binding("extract"),
|
|
Merge: Binding("merge"),
|
|
Normalize: Binding("normalize"),
|
|
},
|
|
typed: &preparedTypedLane{
|
|
extractor: provider("extract"),
|
|
merger: provider("merge"),
|
|
normalizer: provider("normalize"),
|
|
},
|
|
extractValidators: fingerprintTestValidatorChain("extract-validator", provider("extract-validator")),
|
|
mergeValidators: fingerprintTestValidatorChain("merge-validator", provider("merge-validator")),
|
|
normalizeValidators: fingerprintTestValidatorChain("normalize-validator", provider("normalize-validator")),
|
|
}
|
|
prepared.Steps[0].lanes = []preparedLaneExecutor{lane}
|
|
|
|
first, err := collectPreparedCheckpointFingerprints(prepared)
|
|
if err != nil {
|
|
t.Fatalf("collectPreparedCheckpointFingerprints() error = %v, want nil", err)
|
|
}
|
|
second, err := collectPreparedCheckpointFingerprints(prepared)
|
|
if err != nil {
|
|
t.Fatalf("second collection error = %v, want nil", err)
|
|
}
|
|
if !reflect.DeepEqual(first, second) {
|
|
t.Fatalf("fingerprints are not deterministic: %#v / %#v", first, second)
|
|
}
|
|
if len(first) != 10 {
|
|
t.Fatalf("fingerprints = %#v, want all 10 prepared components", first)
|
|
}
|
|
for index := 1; index < len(first); index++ {
|
|
if first[index-1].Name >= first[index].Name {
|
|
t.Fatalf("fingerprints are not sorted: %#v", first)
|
|
}
|
|
}
|
|
prepared.checkpointFingerprints = first
|
|
copy := prepared.CheckpointFingerprints()
|
|
copy[0].Value = "mutated"
|
|
if reflect.DeepEqual(copy, prepared.CheckpointFingerprints()) {
|
|
t.Fatal("CheckpointFingerprints() exposed mutable backing storage")
|
|
}
|
|
}
|
|
|
|
func TestPreparedCheckpointFingerprintsValidateProviderValues(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
fingerprints []CheckpointFingerprint
|
|
want string
|
|
}{
|
|
{name: "empty name", fingerprints: []CheckpointFingerprint{{Value: "value"}}, want: "non-empty name and value"},
|
|
{name: "empty value", fingerprints: []CheckpointFingerprint{{Name: "name"}}, want: "non-empty name and value"},
|
|
{name: "duplicate after trimming", fingerprints: []CheckpointFingerprint{{Name: "same", Value: "one"}, {Name: " same ", Value: "two"}}, want: "duplicated"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
prepared := &PreparedPipeline{
|
|
resolved: ResolvedPipeline{ID: "fingerprints", Input: Binding("input"), Chunk: Binding("chunk"), Output: Binding("output")},
|
|
input: checkpointFingerprintTestProvider(test.fingerprints),
|
|
}
|
|
_, err := collectPreparedCheckpointFingerprints(prepared)
|
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
|
t.Fatalf("error = %v, want substring %q", err, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPreparedCheckpointFingerprintsRemainEmptyWithoutProviders(t *testing.T) {
|
|
registries, _ := constructionRegistries(t, nil, nil)
|
|
resolved, err := ResolvePipeline(constructionProfile(), ResolveOptions{}, registries.catalog())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
prepared, err := Prepare(resolved, registries, ModuleDependencies{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := prepared.CheckpointFingerprints(); len(got) != 0 {
|
|
t.Fatalf("CheckpointFingerprints() = %#v, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestPreparedCheckpointFingerprintsScopeRepeatedValidatorsByPosition(t *testing.T) {
|
|
provider := checkpointFingerprintTestProvider{{Name: "identity", Value: "same"}}
|
|
validator := func() preparedValidator {
|
|
return preparedValidator{resolved: ResolvedValidator{Binding: Binding("configured"), Target: ValidatorTargetChunk}, chunk: provider}
|
|
}
|
|
prepared := &PreparedPipeline{
|
|
resolved: ResolvedPipeline{ID: "fingerprints", Input: Binding("input"), Chunk: Binding("chunk"), Output: Binding("output")},
|
|
chunkValidators: preparedValidatorChain{validators: []preparedValidator{validator(), validator()}},
|
|
}
|
|
got, err := collectPreparedCheckpointFingerprints(prepared)
|
|
if err != nil {
|
|
t.Fatalf("collect repeated validators: %v", err)
|
|
}
|
|
if len(got) != 2 || got[0].Name == got[1].Name {
|
|
t.Fatalf("fingerprints = %#v, want independently scoped repeated validators", got)
|
|
}
|
|
}
|
|
|
|
type checkpointFingerprintTestProvider []CheckpointFingerprint
|
|
|
|
func (p checkpointFingerprintTestProvider) CheckpointFingerprints() []CheckpointFingerprint {
|
|
return append([]CheckpointFingerprint(nil), p...)
|
|
}
|
|
func (checkpointFingerprintTestProvider) Key() string { return "fingerprint-test" }
|
|
func (checkpointFingerprintTestProvider) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
|
func (checkpointFingerprintTestProvider) Parse(context.Context, contracts.ParseRequest) (*source.SourceDocument, error) {
|
|
return typedTestDocument(), nil
|
|
}
|
|
func (checkpointFingerprintTestProvider) Plan(context.Context, contracts.ChunkRequest) (contracts.ChunkPlanResult, error) {
|
|
return contracts.ChunkPlanResult{}, nil
|
|
}
|
|
func (checkpointFingerprintTestProvider) Encode(context.Context, contracts.OutputRequest) (contracts.OutputResult, error) {
|
|
return contracts.OutputResult{}, nil
|
|
}
|
|
func (checkpointFingerprintTestProvider) Name() string { return "fingerprint-test" }
|
|
func (checkpointFingerprintTestProvider) ExecutionClass() contracts.ExecutionClass {
|
|
return contracts.ExecutionClassDeterministic
|
|
}
|
|
func (checkpointFingerprintTestProvider) Validate(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error) {
|
|
return contracts.ValidationResult{Approved: true}, nil
|
|
}
|
|
|
|
type checkpointFingerprintOutput struct {
|
|
contracts.OutputEncoder
|
|
fingerprints []CheckpointFingerprint
|
|
}
|
|
|
|
func (o checkpointFingerprintOutput) CheckpointFingerprints() []CheckpointFingerprint {
|
|
return append([]CheckpointFingerprint(nil), o.fingerprints...)
|
|
}
|
|
|
|
func fingerprintTestValidatorChain(key string, provider checkpointFingerprintTestProvider) preparedValidatorChain {
|
|
return preparedValidatorChain{validators: []preparedValidator{{
|
|
resolved: ResolvedValidator{Binding: Binding(key), Target: ValidatorTargetTyped},
|
|
typed: provider,
|
|
}}}
|
|
}
|
|
|
|
func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
|
|
var built []string
|
|
var observations []constructionBuildObservation
|
|
registries, input := constructionRegistriesWithHooks(t, &built, nil,
|
|
func(name string, request BuildRequest) {
|
|
observations = append(observations, constructionBuildObservation{Name: name, Request: request})
|
|
},
|
|
func(name string, request *BuildRequest) {
|
|
if name != "extract" {
|
|
return
|
|
}
|
|
request.Options["nested"].(map[string]any)["value"] = "mutated by extractor builder"
|
|
slot := request.References.Slots["extract"]
|
|
slot.Items[0].Content = []byte("mutated by extractor builder")
|
|
request.References.Slots["extract"] = slot
|
|
},
|
|
)
|
|
resolved, err := ResolvePipeline(constructionProfile(), ResolveOptions{}, registries.catalog())
|
|
if err != nil {
|
|
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
|
|
}
|
|
resolved.ChunkReferences.ReferenceSet = constructionReferenceSet("chunk", "chunk reference")
|
|
resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet = constructionReferenceSet("extract", "extract reference")
|
|
resolved.Steps[0].ArtifactLanes[0].MergeReferences.ReferenceSet = constructionReferenceSet("merge", "merge reference")
|
|
resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.ReferenceSet = constructionReferenceSet("normalize", "normalize reference")
|
|
resolved.Steps[0].ArtifactLanes[0].Extract.Options = constructionBuildRequest().Options
|
|
|
|
prepared, err := Prepare(resolved, registries, ModuleDependencies{})
|
|
if err != nil {
|
|
t.Fatalf("Prepare() error = %v, want nil", err)
|
|
}
|
|
wantNames := []string{"input", "chunk", "validator", "extract", "validator", "merge", "validator", "normalize", "validator", "output"}
|
|
if !reflect.DeepEqual(built, wantNames) {
|
|
t.Fatalf("construction order = %#v, want %#v", built, wantNames)
|
|
}
|
|
wantContents := []string{"", "chunk reference", "chunk reference", "extract reference", "extract reference", "merge reference", "merge reference", "normalize reference", "normalize reference", ""}
|
|
if len(observations) != len(wantContents) {
|
|
t.Fatalf("observed %d build requests, want %d", len(observations), len(wantContents))
|
|
}
|
|
for i, want := range wantContents {
|
|
if got := constructionReferenceContent(observations[i].Request.References); got != want {
|
|
t.Errorf("build request %d (%s) reference content = %q, want %q", i, observations[i].Name, got, want)
|
|
}
|
|
}
|
|
if got := constructionReferenceContent(resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet); got != "extract reference" {
|
|
t.Fatalf("resolved extract references = %q, want original content", got)
|
|
}
|
|
if got := constructionReferenceContent(prepared.resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet); got != "extract reference" {
|
|
t.Fatalf("prepared extract references = %q, want original content", got)
|
|
}
|
|
if got := resolved.Steps[0].ArtifactLanes[0].Extract.Options["nested"].(map[string]any)["value"]; got != "original" {
|
|
t.Fatalf("resolved extract options = %#v, want original nested value", got)
|
|
}
|
|
if got := prepared.resolved.Steps[0].ArtifactLanes[0].Extract.Options["nested"].(map[string]any)["value"]; got != "original" {
|
|
t.Fatalf("prepared extract options = %#v, want original nested value", got)
|
|
}
|
|
|
|
_, err = prepared.Steps[0].lanes[0].typed.extract(context.Background(), prepared.Steps[0].lanes[0].typed.extractor, contracts.TypedExtractionRequest{
|
|
References: CloneReferenceSet(resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("prepared extractor operation error = %v, want nil", err)
|
|
}
|
|
if len(input.extractRequests) != 1 {
|
|
t.Fatalf("runtime extraction requests = %d, want one", len(input.extractRequests))
|
|
}
|
|
if got := constructionReferenceContent(input.extractRequests[0].References); got != "extract reference" {
|
|
t.Fatalf("runtime extraction references = %q, want original content", got)
|
|
}
|
|
}
|
|
|
|
func TestRegisteredBuildersReceiveIndependentBuildRequests(t *testing.T) {
|
|
request := constructionBuildRequest()
|
|
probe := buildRequestMutationProbe{t: t, want: cloneBuildRequest(request)}
|
|
|
|
inputs := NewInputAdapterRegistry()
|
|
if err := inputs.RegisterBuilderWithSpec(testModuleSpec("input", StageInput), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.InputAdapter, error) {
|
|
probe.observe(request)
|
|
return &constructionInput{key: "input"}, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
chunkers := NewChunkerRegistry()
|
|
if err := chunkers.RegisterBuilderWithSpec(testModuleSpec("chunk", StageChunk), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Chunker, error) {
|
|
probe.observe(request)
|
|
return &typedTestChunker{key: "chunk"}, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
outputs := NewOutputEncoderRegistry()
|
|
if err := outputs.RegisterBuilderWithSpec(testModuleSpec("output", StageOutput), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.OutputEncoder, error) {
|
|
probe.observe(request)
|
|
return &typedTestOutput{key: "output"}, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
extractors := NewExtractorRegistry()
|
|
extractSpec := testModuleSpec("extract", StageExtract)
|
|
extractSpec.ArtifactKind = "test/notes"
|
|
if err := RegisterExtractorBuilder(extractors, extractSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Extractor[codecNotes], error) {
|
|
probe.observe(request)
|
|
return typedTestExtractor[codecNotes]{key: "extract"}, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mergers := NewMergerRegistry()
|
|
mergeSpec := testModuleSpec("merge", StageMerge)
|
|
mergeSpec.ArtifactKind = "test/notes"
|
|
if err := RegisterMergerBuilder(mergers, mergeSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Merger[codecNotes], error) {
|
|
probe.observe(request)
|
|
return typedTestMerger[codecNotes]{key: "merge"}, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
normalizers := NewNormalizerRegistry()
|
|
normalizeSpec := testModuleSpec("normalize", StageNormalize)
|
|
normalizeSpec.ArtifactKind = "test/notes"
|
|
if err := RegisterNormalizerBuilder(normalizers, normalizeSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Normalizer[codecNotes], error) {
|
|
probe.observe(request)
|
|
return typedTestNormalizer[codecNotes]{key: "normalize"}, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
validators := NewValidatorRegistry()
|
|
if err := RegisterChunkValidatorBuilder(validators, ValidatorSpec{Key: "chunk-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.ChunkValidator, error) {
|
|
probe.observe(request)
|
|
return typedTestChunkValidator{key: "chunk-validator"}, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := RegisterTypedValidatorBuilder(validators, "test/notes", ValidatorSpec{Key: "typed-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.TypedValidator[codecNotes], error) {
|
|
probe.observe(request)
|
|
return typedTestValidator[codecNotes]{key: "typed-validator"}, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := RegisterSerializedValidatorBuilder(validators, SerializedValidatorSpec{ValidatorSpec: ValidatorSpec{Key: "serialized-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, SupportsArtifacts: true}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.SerializedValidator, error) {
|
|
probe.observe(request)
|
|
return typedTestSerializedValidator{key: "serialized-validator"}, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
builders := []struct {
|
|
name string
|
|
call func() error
|
|
}{
|
|
{name: "input", call: func() error { _, err := inputs.BuildWithRequest("input", request); return err }},
|
|
{name: "chunk", call: func() error { _, err := chunkers.BuildWithRequest("chunk", request); return err }},
|
|
{name: "output", call: func() error { _, err := outputs.BuildWithRequest("output", request); return err }},
|
|
{name: "extract", call: func() error {
|
|
entry, _ := extractors.typedEntry("extract")
|
|
_, err := entry.builder(request)
|
|
return err
|
|
}},
|
|
{name: "merge", call: func() error {
|
|
entry, _ := mergers.typedEntry("merge", "test/notes")
|
|
_, err := entry.builder(request)
|
|
return err
|
|
}},
|
|
{name: "normalize", call: func() error {
|
|
entry, _ := normalizers.typedEntry("normalize", "test/notes")
|
|
_, err := entry.builder(request)
|
|
return err
|
|
}},
|
|
{name: "chunk validator", call: func() error {
|
|
entry, _ := validators.chunkEntry("chunk-validator")
|
|
_, err := entry.builder(request)
|
|
return err
|
|
}},
|
|
{name: "typed validator", call: func() error {
|
|
entry, _ := validators.typedEntry("typed-validator", "test/notes")
|
|
_, err := entry.builder(request)
|
|
return err
|
|
}},
|
|
{name: "serialized validator", call: func() error {
|
|
entry, _ := validators.serializedEntry("serialized-validator")
|
|
_, err := entry.builder(request)
|
|
return err
|
|
}},
|
|
}
|
|
for _, builder := range builders {
|
|
t.Run(builder.name, func(t *testing.T) {
|
|
if err := builder.call(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
}
|
|
if !reflect.DeepEqual(request, probe.want) {
|
|
t.Fatalf("build request = %#v, want unchanged %#v", request, probe.want)
|
|
}
|
|
}
|
|
|
|
type buildRequestMutationProbe struct {
|
|
t *testing.T
|
|
want BuildRequest
|
|
}
|
|
|
|
func (probe buildRequestMutationProbe) observe(request BuildRequest) {
|
|
probe.t.Helper()
|
|
if !reflect.DeepEqual(request, probe.want) {
|
|
probe.t.Fatalf("builder request = %#v, want independently owned %#v", request, probe.want)
|
|
}
|
|
options := request.Options["nested"].(map[string]any)
|
|
options["value"] = "mutated"
|
|
request.Options["items"].([]any)[0].(map[string]any)["value"] = "mutated"
|
|
request.Options["bytes"].([]byte)[0] = 'x'
|
|
slot := request.References.Slots["reference"]
|
|
slot.Items[0].Content[0] = 'x'
|
|
slot.Items = append(slot.Items, contracts.ReferenceItem{SlotName: "reference", Content: []byte("extra")})
|
|
request.References.Slots["reference"] = slot
|
|
delete(request.References.Slots, "unused")
|
|
}
|
|
|
|
func constructionBuildRequest() BuildRequest {
|
|
return BuildRequest{
|
|
Options: map[string]any{
|
|
"nested": map[string]any{"value": "original"},
|
|
"items": []any{map[string]any{"value": "original"}},
|
|
"bytes": []byte("original"),
|
|
},
|
|
References: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
|
"reference": {Slot: contracts.ReferenceSlot{Name: "reference"}, Items: []contracts.ReferenceItem{{SlotName: "reference", Content: []byte("original")}}},
|
|
"unused": {Slot: contracts.ReferenceSlot{Name: "unused"}},
|
|
}},
|
|
}
|
|
}
|
|
|
|
func TestPrepareFailuresOccurBeforeInputParse(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
deps ModuleDependencies
|
|
configure func(*constructionFailure)
|
|
want string
|
|
wantBuilt []string
|
|
}{
|
|
{
|
|
name: "missing required llm dependency",
|
|
configure: func(failure *constructionFailure) {
|
|
failure.requireExtractorLLM = true
|
|
},
|
|
want: `lane "artifact" extract module "extract"`,
|
|
wantBuilt: []string{"input", "chunk", "validator", "extract"},
|
|
},
|
|
{
|
|
name: "late output construction",
|
|
configure: func(failure *constructionFailure) {
|
|
failure.output = errors.New("output unavailable")
|
|
},
|
|
want: `output module "output"`,
|
|
wantBuilt: []string{"input", "chunk", "validator", "extract", "validator", "merge", "validator", "normalize", "validator", "output"},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
failure := &constructionFailure{}
|
|
test.configure(failure)
|
|
var built []string
|
|
registries, input := constructionRegistries(t, &built, failure)
|
|
resolved, err := ResolvePipeline(constructionProfile(), ResolveOptions{}, registries.catalog())
|
|
if err != nil {
|
|
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
|
|
}
|
|
|
|
_, err = Prepare(resolved, registries, test.deps)
|
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
|
t.Fatalf("Prepare() error = %v, want substring %q", err, test.want)
|
|
}
|
|
if len(input.requests) != 0 {
|
|
t.Fatalf("input Parse calls = %d, want zero", len(input.requests))
|
|
}
|
|
if !reflect.DeepEqual(built, test.wantBuilt) {
|
|
t.Fatalf("construction order = %#v, want %#v", built, test.wantBuilt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPrepareRejectsInvalidComponentCheckpointFingerprint(t *testing.T) {
|
|
failure := &constructionFailure{outputFingerprints: []CheckpointFingerprint{{Name: "identity"}}}
|
|
registries, input := constructionRegistries(t, nil, failure)
|
|
resolved, err := ResolvePipeline(constructionProfile(), ResolveOptions{}, registries.catalog())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = Prepare(resolved, registries, ModuleDependencies{})
|
|
if err == nil || !strings.Contains(err.Error(), "checkpoint fingerprint") || !strings.Contains(err.Error(), "output:output") {
|
|
t.Fatalf("Prepare() error = %v, want scoped checkpoint fingerprint error", err)
|
|
}
|
|
if len(input.requests) != 0 {
|
|
t.Fatalf("input Parse calls = %d, want zero", len(input.requests))
|
|
}
|
|
}
|
|
|
|
type constructionFailure struct {
|
|
requireExtractorLLM bool
|
|
output error
|
|
outputFingerprints []CheckpointFingerprint
|
|
}
|
|
|
|
func constructionProfile() PipelineProfile {
|
|
validators := ValidatorOverride{Set: true, Validators: []ModuleBinding{{Module: "configured"}}}
|
|
return PipelineProfile{
|
|
ID: "construction",
|
|
Input: Binding("input"),
|
|
Chunk: ModuleBinding{Module: "chunk", Validators: validators},
|
|
Artifacts: map[string]ArtifactLaneProfile{
|
|
"artifact": {
|
|
Extract: ModuleBinding{Module: "extract", Validators: validators},
|
|
Merge: ModuleBinding{Module: "merge", Validators: validators},
|
|
Normalize: ModuleBinding{Module: "normalize", Validators: validators},
|
|
},
|
|
},
|
|
Output: Binding("output"),
|
|
}
|
|
}
|
|
|
|
func constructionRegistries(t *testing.T, built *[]string, failure *constructionFailure) (Registries, *constructionInput) {
|
|
return constructionRegistriesWithHooks(t, built, failure, nil, nil)
|
|
}
|
|
|
|
type constructionBuildObservation struct {
|
|
Name string
|
|
Request BuildRequest
|
|
}
|
|
|
|
func constructionRegistriesWithHooks(t *testing.T, built *[]string, failure *constructionFailure, observe func(string, BuildRequest), mutate func(string, *BuildRequest)) (Registries, *constructionInput) {
|
|
t.Helper()
|
|
if built == nil {
|
|
built = &[]string{}
|
|
}
|
|
if failure == nil {
|
|
failure = &constructionFailure{}
|
|
}
|
|
record := func(name string, request *BuildRequest) {
|
|
*built = append(*built, name)
|
|
if observe != nil {
|
|
observe(name, cloneBuildRequest(*request))
|
|
}
|
|
if mutate != nil {
|
|
mutate(name, request)
|
|
}
|
|
}
|
|
strict := func(options map[string]any) error { return RejectUnknownOptions(options, "known") }
|
|
input := &constructionInput{key: "input"}
|
|
registries := Registries{
|
|
Inputs: NewInputAdapterRegistry(), Chunkers: NewChunkerRegistry(), ArtifactCodecs: NewArtifactCodecRegistry(),
|
|
Extractors: NewExtractorRegistry(), Mergers: NewMergerRegistry(), Normalizers: NewNormalizerRegistry(),
|
|
Validators: NewValidatorRegistry(), ValidatorChains: NewValidatorChainRegistry(), Outputs: NewOutputEncoderRegistry(),
|
|
}
|
|
if err := RegisterArtifactCodec(registries.ArtifactCodecs, notesCodec()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := registries.Inputs.RegisterBuilderWithSpec(testModuleSpec("input", StageInput), strict, func(request BuildRequest) (contracts.InputAdapter, error) {
|
|
record("input", &request)
|
|
return input, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := registries.Chunkers.RegisterBuilderWithSpec(testModuleSpec("chunk", StageChunk), strict, func(request BuildRequest) (contracts.Chunker, error) {
|
|
record("chunk", &request)
|
|
return &typedTestChunker{key: "chunk"}, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
extractSpec := testModuleSpec("extract", StageExtract)
|
|
extractSpec.ArtifactKind = "test/notes"
|
|
if err := RegisterExtractorBuilder(registries.Extractors, extractSpec, strict, func(request BuildRequest) (contracts.Extractor[codecNotes], error) {
|
|
record("extract", &request)
|
|
if failure.requireExtractorLLM && request.Dependencies.LLM == nil {
|
|
return nil, errors.New("structured LLM client is required")
|
|
}
|
|
return &constructionExtractor{key: "extract", requests: &input.extractRequests}, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mergeSpec := testModuleSpec("merge", StageMerge)
|
|
mergeSpec.ArtifactKind = "test/notes"
|
|
if err := RegisterMergerBuilder(registries.Mergers, mergeSpec, strict, func(request BuildRequest) (contracts.Merger[codecNotes], error) {
|
|
record("merge", &request)
|
|
return typedTestMerger[codecNotes]{key: "merge"}, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
normalizeSpec := testModuleSpec("normalize", StageNormalize)
|
|
normalizeSpec.ArtifactKind = "test/notes"
|
|
if err := RegisterNormalizerBuilder(registries.Normalizers, normalizeSpec, strict, func(request BuildRequest) (contracts.Normalizer[codecNotes], error) {
|
|
record("normalize", &request)
|
|
return typedTestNormalizer[codecNotes]{key: "normalize"}, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
validatorSpec := ValidatorSpec{Key: "configured", ExecutionClass: contracts.ExecutionClassDeterministic}
|
|
if err := RegisterChunkValidatorBuilder(registries.Validators, validatorSpec, strict, func(request BuildRequest) (contracts.ChunkValidator, error) {
|
|
record("validator", &request)
|
|
return typedTestChunkValidator{key: "configured"}, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := RegisterTypedValidatorBuilder(registries.Validators, "test/notes", validatorSpec, strict, func(request BuildRequest) (contracts.TypedValidator[codecNotes], error) {
|
|
record("validator", &request)
|
|
return typedTestValidator[codecNotes]{key: "configured"}, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := registries.Outputs.RegisterBuilderWithSpec(testModuleSpec("output", StageOutput), strict, func(request BuildRequest) (contracts.OutputEncoder, error) {
|
|
record("output", &request)
|
|
if failure.output != nil {
|
|
return nil, failure.output
|
|
}
|
|
output := contracts.OutputEncoder(&typedTestOutput{key: "output"})
|
|
if failure.outputFingerprints != nil {
|
|
output = checkpointFingerprintOutput{OutputEncoder: output, fingerprints: failure.outputFingerprints}
|
|
}
|
|
return output, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return registries, input
|
|
}
|
|
|
|
type constructionInput struct {
|
|
key string
|
|
requests []contracts.ParseRequest
|
|
extractRequests []contracts.TypedExtractionRequest
|
|
}
|
|
|
|
type constructionExtractor struct {
|
|
key string
|
|
requests *[]contracts.TypedExtractionRequest
|
|
}
|
|
|
|
func (extractor *constructionExtractor) Key() string { return extractor.key }
|
|
func (*constructionExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
|
func (extractor *constructionExtractor) Extract(_ context.Context, request contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[codecNotes], error) {
|
|
if extractor.requests != nil {
|
|
*extractor.requests = append(*extractor.requests, request)
|
|
}
|
|
return contracts.TypedExtractionResult[codecNotes]{}, nil
|
|
}
|
|
|
|
func constructionReferenceSet(slotName, content string) contracts.ReferenceSet {
|
|
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
|
slotName: {
|
|
Slot: contracts.ReferenceSlot{Name: slotName},
|
|
Items: []contracts.ReferenceItem{{SlotName: slotName, Content: []byte(content)}},
|
|
},
|
|
}}
|
|
}
|
|
|
|
func constructionReferenceContent(references contracts.ReferenceSet) string {
|
|
for _, slot := range references.Slots {
|
|
if len(slot.Items) > 0 {
|
|
return string(slot.Items[0].Content)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (input *constructionInput) Key() string { return input.key }
|
|
func (input *constructionInput) Parse(_ context.Context, request contracts.ParseRequest) (*source.SourceDocument, error) {
|
|
input.requests = append(input.requests, request)
|
|
return typedTestDocument(), nil
|
|
}
|