Files
narratio/internal/config/composition_test.go

357 lines
12 KiB
Go

package config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestParseCompositionDocumentRetainsPresenceOwnershipAndDeclarationOrder(t *testing.T) {
document := mustParseComposition(t, "root.yml", `zeta: false
zero: 0
empty_map: {}
empty_list: []
nested:
value: ""
`)
wantOrder := []string{"zeta", "zero", "empty_map", "empty_list", "nested"}
gotOrder := make([]string, 0, len(document.root.fields))
for _, field := range document.root.fields {
gotOrder = append(gotOrder, field.key)
}
if !reflect.DeepEqual(gotOrder, wantOrder) {
t.Fatalf("declaration order = %#v, want %#v", gotOrder, wantOrder)
}
tests := []struct {
path string
kind yaml.Kind
tag string
value string
}{
{path: "zeta", kind: yaml.ScalarNode, tag: "!!bool", value: "false"},
{path: "zero", kind: yaml.ScalarNode, tag: "!!int", value: "0"},
{path: "empty_map", kind: yaml.MappingNode, tag: "!!map"},
{path: "empty_list", kind: yaml.SequenceNode, tag: "!!seq"},
{path: "nested.value", kind: yaml.ScalarNode, tag: "!!str", value: ""},
}
for _, tt := range tests {
node := compositionNodeAtPath(t, document.root, tt.path)
if node.kind != tt.kind || node.tag != tt.tag || node.value != tt.value || !reflect.DeepEqual(node.sources, []string{"root.yml"}) {
t.Fatalf("node %s = kind=%v tag=%q value=%q sources=%#v", tt.path, node.kind, node.tag, node.value, node.sources)
}
}
}
func TestParseCompositionDocumentRejectsAmbiguousOrMalformedYAML(t *testing.T) {
tests := []struct {
name string
yaml string
want string
}{
{name: "duplicate top-level key", yaml: "value: 1\nvalue: 2\n", want: "duplicate YAML key"},
{name: "duplicate nested key", yaml: "outer:\n value: 1\n value: 2\n", want: "outer.value"},
{name: "alias", yaml: "base: &base\n value: 1\ncopy: *base\n", want: "aliases are not supported"},
{name: "trailing document", yaml: "value: 1\n---\nvalue: 2\n", want: "exactly one YAML document"},
{name: "top-level sequence", yaml: "- value\n", want: "top-level document must be a mapping"},
{name: "non-string key", yaml: "1: value\n", want: "mapping keys must be strings"},
{name: "malformed", yaml: "outer: [\n", want: "decode YAML"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := parseCompositionDocument("broken.yml", strings.NewReader(tt.yaml))
if err == nil {
t.Fatal("parseCompositionDocument() error = nil")
}
if !strings.Contains(err.Error(), "broken.yml") || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("error = %q, want source and %q", err, tt.want)
}
})
}
}
func TestMergeAdditiveCompositionJoinsOnlyDisjointMappings(t *testing.T) {
root := mustParseComposition(t, "pipeline.yml", `scriptorium:
artifacts:
recap:
enabled: true
zero: 0
`)
imports := mustParseComposition(t, "conf.d/artifacts.yml", `scriptorium:
artifacts:
handout:
enabled: false
empty_list: []
`)
merged, err := mergeAdditiveComposition(root, imports)
if err != nil {
t.Fatalf("mergeAdditiveComposition() error = %v", err)
}
for _, path := range []string{
"empty_list", "scriptorium.artifacts.handout.enabled",
"scriptorium.artifacts.recap.enabled", "zero",
} {
_ = compositionNodeAtPath(t, merged.root, path)
}
if got := compositionNodeAtPath(t, merged.root, "scriptorium.artifacts.handout.enabled").sources; !reflect.DeepEqual(got, []string{"conf.d/artifacts.yml"}) {
t.Fatalf("handout sources = %#v", got)
}
if got := compositionNodeAtPath(t, merged.root, "scriptorium.artifacts.recap.enabled").sources; !reflect.DeepEqual(got, []string{"pipeline.yml"}) {
t.Fatalf("recap sources = %#v", got)
}
// Merge operations return a new document and retain the declared order in
// each input for source-aware diagnostics.
if len(root.root.fields) != 2 || len(imports.root.fields) != 2 {
t.Fatalf("merge mutated inputs: root=%d import=%d", len(root.root.fields), len(imports.root.fields))
}
}
func TestMergeAdditiveCompositionRejectsEveryDuplicateClass(t *testing.T) {
tests := []struct {
name string
baseYAML string
nextYAML string
path string
}{
{name: "equal scalar", baseYAML: "value: true\n", nextYAML: "value: true\n", path: "value"},
{name: "different scalar", baseYAML: "value: true\n", nextYAML: "value: false\n", path: "value"},
{name: "atomic list", baseYAML: "values: [one]\n", nextYAML: "values: [two]\n", path: "values"},
{name: "keyed entry", baseYAML: "items:\n shared:\n left: 1\n", nextYAML: "items:\n shared:\n left: 2\n", path: "items.shared.left"},
{name: "kind conflict", baseYAML: "value:\n nested: true\n", nextYAML: "value: scalar\n", path: "value"},
{name: "duplicate empty map", baseYAML: "value: {}\n", nextYAML: "value: {}\n", path: "value"},
{name: "empty map then populated map", baseYAML: "value: {}\n", nextYAML: "value: {nested: true}\n", path: "value"},
{name: "populated map then empty map", baseYAML: "value: {nested: true}\n", nextYAML: "value: {}\n", path: "value"},
{name: "duplicate empty list", baseYAML: "value: []\n", nextYAML: "value: []\n", path: "value"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
base := mustParseComposition(t, "base.yml", tt.baseYAML)
next := mustParseComposition(t, "next.yml", tt.nextYAML)
_, err := mergeAdditiveComposition(base, next)
if err == nil {
t.Fatal("mergeAdditiveComposition() error = nil")
}
for _, want := range []string{tt.path, "base.yml", "next.yml"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %q, want %q", err, want)
}
}
})
}
}
func TestMergeAdditiveCompositionReportsAllClaimingSources(t *testing.T) {
left := mustParseComposition(t, "left.yml", "group:\n left: 1\n")
right := mustParseComposition(t, "right.yml", "group:\n right: 2\n")
base, err := mergeAdditiveComposition(left, right)
if err != nil {
t.Fatal(err)
}
overlap := mustParseComposition(t, "overlap.yml", "group: scalar\n")
_, err = mergeAdditiveComposition(base, overlap)
if err == nil {
t.Fatal("mergeAdditiveComposition() error = nil")
}
for _, want := range []string{"group", "left.yml", "right.yml", "overlap.yml"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %q, want %q", err, want)
}
}
}
func TestMergeOverlayCompositionRecursesMapsAndReplacesAtomicValues(t *testing.T) {
base := mustParseComposition(t, "base.yml", `feature:
enabled: true
retries: 3
values: [one, two]
inherited: kept
artifacts:
recap:
enabled: true
`)
overlay := mustParseComposition(t, "testing.yml", `feature:
enabled: false
retries: 0
values: []
added: present
artifacts:
handout:
enabled: false
`)
merged, err := mergeOverlayComposition(base, overlay)
if err != nil {
t.Fatalf("mergeOverlayComposition() error = %v", err)
}
assertCompositionScalar(t, merged.root, "feature.enabled", "!!bool", "false", "testing.yml")
assertCompositionScalar(t, merged.root, "feature.retries", "!!int", "0", "testing.yml")
assertCompositionScalar(t, merged.root, "feature.inherited", "!!str", "kept", "base.yml")
assertCompositionScalar(t, merged.root, "feature.added", "!!str", "present", "testing.yml")
if values := compositionNodeAtPath(t, merged.root, "feature.values"); values.kind != yaml.SequenceNode || len(values.items) != 0 || !reflect.DeepEqual(values.sources, []string{"testing.yml"}) {
t.Fatalf("replaced list = %#v", values)
}
_ = compositionNodeAtPath(t, merged.root, "artifacts.recap.enabled")
_ = compositionNodeAtPath(t, merged.root, "artifacts.handout.enabled")
}
func TestMergeOverlayCompositionRejectsKindChangesAndNullDeletion(t *testing.T) {
kindTests := []struct {
name string
baseYAML string
overlay string
}{
{name: "map to scalar", baseYAML: "value: {nested: true}\n", overlay: "value: replacement\n"},
{name: "scalar to map", baseYAML: "value: original\n", overlay: "value: {nested: true}\n"},
{name: "list to scalar", baseYAML: "value: [one]\n", overlay: "value: replacement\n"},
{name: "scalar to list", baseYAML: "value: original\n", overlay: "value: [one]\n"},
}
for _, tt := range kindTests {
t.Run(tt.name, func(t *testing.T) {
_, err := mergeOverlayComposition(
mustParseComposition(t, "base.yml", tt.baseYAML),
mustParseComposition(t, "overlay.yml", tt.overlay),
)
if err == nil || !strings.Contains(err.Error(), "value") || !strings.Contains(err.Error(), "kind change") {
t.Fatalf("error = %v, want value kind change", err)
}
})
}
for _, overlayYAML := range []string{"value: null\n", "value: ~\n", "nested:\n value:\n"} {
_, err := mergeOverlayComposition(
mustParseComposition(t, "base.yml", "value: original\nnested:\n value: original\n"),
mustParseComposition(t, "overlay.yml", overlayYAML),
)
if err == nil || !strings.Contains(err.Error(), "null cannot delete") || !strings.Contains(err.Error(), "overlay.yml") {
t.Fatalf("error = %v, want source-qualified null rejection", err)
}
}
}
func TestCompositionCanonicalOutputsAreDeterministic(t *testing.T) {
first := mustParseComposition(t, "first.yml", `zeta: 01
alpha:
list: [true, false]
empty: {}
`)
second := mustParseComposition(t, "second.yml", `alpha:
empty: {}
list:
- true
- false
zeta: 1
`)
firstYAML, err := first.canonicalYAML()
if err != nil {
t.Fatal(err)
}
secondYAML, err := second.canonicalYAML()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(firstYAML, secondYAML) {
t.Fatalf("canonical YAML differs:\n%s\n---\n%s", firstYAML, secondYAML)
}
firstDigest, err := first.canonicalDigestInput()
if err != nil {
t.Fatal(err)
}
secondDigest, err := second.canonicalDigestInput()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(firstDigest, secondDigest) {
t.Fatalf("digest input differs:\n%s\n---\n%s", firstDigest, secondDigest)
}
left := mustParseComposition(t, "left.yml", "zeta: 1\n")
right := mustParseComposition(t, "right.yml", "alpha: 2\n")
leftRight, err := mergeAdditiveComposition(left, right)
if err != nil {
t.Fatal(err)
}
rightLeft, err := mergeAdditiveComposition(right, left)
if err != nil {
t.Fatal(err)
}
want, _ := leftRight.canonicalDigestInput()
got, _ := rightLeft.canonicalDigestInput()
if !reflect.DeepEqual(got, want) {
t.Fatalf("source traversal changed semantic digest input: got %s want %s", got, want)
}
wantYAML, _ := leftRight.canonicalYAML()
gotYAML, _ := rightLeft.canonicalYAML()
if !reflect.DeepEqual(gotYAML, wantYAML) {
t.Fatalf("source traversal changed canonical YAML: got %s want %s", gotYAML, wantYAML)
}
records, err := first.semanticRecords()
if err != nil {
t.Fatal(err)
}
paths := make([]string, 0, len(records))
for _, record := range records {
paths = append(paths, record.Path)
}
if wantPaths := []string{"alpha.empty", "alpha.list", "zeta"}; !reflect.DeepEqual(paths, wantPaths) {
t.Fatalf("semantic record paths = %#v, want %#v", paths, wantPaths)
}
}
func TestPipelineCompositionEnvelopeIsNotPublicYet(t *testing.T) {
path := filepath.Join(t.TempDir(), "pipeline.yml")
if err := os.WriteFile(path, []byte(`composition:
imports: []
whisperx:
transcribe_url: https://transcription.example.com/transcribe
`), 0o644); err != nil {
t.Fatal(err)
}
_, err := LoadPipeline(path)
if err == nil || !strings.Contains(err.Error(), "field composition not found") {
t.Fatalf("LoadPipeline() error = %v, want strict public-schema rejection", err)
}
}
func mustParseComposition(t *testing.T, source, input string) *compositionDocument {
t.Helper()
document, err := parseCompositionBytes(source, []byte(input))
if err != nil {
t.Fatalf("parseCompositionBytes(%q) error = %v", source, err)
}
return document
}
func compositionNodeAtPath(t *testing.T, root *compositionNode, path string) *compositionNode {
t.Helper()
node := root
for _, segment := range strings.Split(path, ".") {
if node == nil || node.kind != yaml.MappingNode {
t.Fatalf("path %q reached non-mapping at %q", path, segment)
}
index := compositionFieldIndex(node.fields, segment)
if index < 0 {
t.Fatalf("path %q missing segment %q", path, segment)
}
node = node.fields[index].value
}
return node
}
func assertCompositionScalar(t *testing.T, root *compositionNode, path, tag, value, source string) {
t.Helper()
node := compositionNodeAtPath(t, root, path)
if node.kind != yaml.ScalarNode || node.tag != tag || node.value != value || !reflect.DeepEqual(node.sources, []string{source}) {
t.Fatalf("%s = kind=%s tag=%q value=%q sources=%#v", path, yamlKindName(node.kind), node.tag, node.value, node.sources)
}
}