357 lines
12 KiB
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)
|
|
}
|
|
}
|