Apply sequence policy across loading tiers
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestCompileSequencePolicyNormalizesRegistrations(t *testing.T) {
|
||||
@@ -125,3 +126,181 @@ func requireSequenceRegistrationOptionError(t *testing.T, err error) {
|
||||
t.Fatalf("diagnostics = %#v, want one options diagnostic", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
type item struct {
|
||||
ID string `yaml:"id"`
|
||||
}
|
||||
type config struct {
|
||||
Items []item `yaml:"items"`
|
||||
}
|
||||
definition := Definition[config]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}}
|
||||
registered := Options[config]{Definition: definition}
|
||||
loadCode := func(t *testing.T, err error) Code {
|
||||
t.Helper()
|
||||
var loadErr *LoadError
|
||||
if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 {
|
||||
t.Fatalf("error = %v, want a diagnostic load error", err)
|
||||
}
|
||||
return loadErr.Diagnostics()[0].Code
|
||||
}
|
||||
|
||||
t.Run("ordinary authored list is checked with one source", func(t *testing.T) {
|
||||
if _, err := testLoad[config](Bytes("main", []byte("items: [{id: same}, {id: same}]\n")), registered); err == nil || loadCode(t, err) != CodeComposeConflict {
|
||||
t.Fatalf("duplicate identity error = %v", err)
|
||||
}
|
||||
result, err := testLoad[config](Bytes("main", []byte("items: [{id: one}]\n")), registered)
|
||||
if err != nil || len(result.Config.Items) != 1 || result.Config.Items[0].ID != "one" {
|
||||
t.Fatalf("single-source load = %#v, %v", result, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ordinary imports retain registered composition", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\nitems: [{id: authored}]\n")
|
||||
writeImportFile(t, directory, "fragment.yml", "items: [{id: imported}]\n")
|
||||
result, err := testLoad[config](File(mainPath), Options[config]{Imports: true, Definition: definition})
|
||||
if err != nil || len(result.Config.Items) != 2 || result.Config.Items[1].ID != "imported" {
|
||||
t.Fatalf("registered imported list = %#v, %v", result, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("strict underlay group appends while separate tiers reset identities", func(t *testing.T) {
|
||||
request := loadRequest[config]{
|
||||
main: loadInput{source: Bytes("main", []byte("items: [{id: shared}]\n"))},
|
||||
underlays: []loadGroup{{inputs: []loadInput{
|
||||
{source: Bytes("first", []byte("items: [{id: lower}]\n"))},
|
||||
{source: Bytes("second", []byte("items: [{id: other}]\n"))},
|
||||
}}},
|
||||
definition: definition, sequencePolicy: mustCompileSequencePolicy(t, definition.sequenceRegistrations),
|
||||
}
|
||||
result, err := testGroupedLoad(request)
|
||||
if err != nil || len(result.Config.Items) != 1 || result.Config.Items[0].ID != "shared" {
|
||||
t.Fatalf("grouped load = %#v, %v", result, err)
|
||||
}
|
||||
|
||||
request.underlays = []loadGroup{{inputs: []loadInput{
|
||||
{source: Bytes("first", []byte("items: [{id: shared}]\n"))},
|
||||
{source: Bytes("second", []byte("items: [{id: lower}]\n"))},
|
||||
}}}
|
||||
request.main.source = Bytes("main", []byte("items: [{id: shared}]\n"))
|
||||
if _, err := testGroupedLoad(request); err != nil {
|
||||
t.Fatalf("same identity in a separate tier: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty registered contribution clears lower sequence", func(t *testing.T) {
|
||||
result, err := testLoad[config](Bytes("main", []byte("items: []\n")), registered)
|
||||
if err != nil || len(result.Config.Items) != 0 {
|
||||
t.Fatalf("empty sequence load = %#v, %v", result, err)
|
||||
}
|
||||
request := loadRequest[config]{
|
||||
main: loadInput{source: Bytes("main", []byte("items: []\n"))},
|
||||
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("items: [{id: lower}]\n"))}}}},
|
||||
definition: definition, sequencePolicy: mustCompileSequencePolicy(t, definition.sequenceRegistrations),
|
||||
}
|
||||
result, err = testGroupedLoad(request)
|
||||
if err != nil || len(result.Config.Items) != 0 {
|
||||
t.Fatalf("empty higher tier = %#v, %v", result, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unregistered list overlap remains atomic", func(t *testing.T) {
|
||||
request := loadRequest[config]{
|
||||
main: loadInput{source: Bytes("main", []byte("items: [{id: main}]\n"))},
|
||||
underlays: []loadGroup{{inputs: []loadInput{
|
||||
{source: Bytes("first", []byte("items: [{id: lower}]\n"))},
|
||||
{source: Bytes("second", []byte("items: [{id: other}]\n"))},
|
||||
}}},
|
||||
}
|
||||
_, err := testGroupedLoad(request)
|
||||
if got := loadCode(t, err); got != CodeComposeConflict {
|
||||
t.Fatalf("unregistered overlap error = %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed lower tier fails before precedence replacement", func(t *testing.T) {
|
||||
request := loadRequest[config]{
|
||||
main: loadInput{source: Bytes("main", []byte("items: [{id: main}]\n"))},
|
||||
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("items: [{id: same}, {id: same}]\n"))}}}},
|
||||
definition: definition, sequencePolicy: mustCompileSequencePolicy(t, definition.sequenceRegistrations),
|
||||
}
|
||||
if got := loadCode(t, func() error { _, err := testGroupedLoad(request); return err }()); got != CodeComposeConflict {
|
||||
t.Fatalf("hidden lower-tier error = %s", got)
|
||||
}
|
||||
|
||||
overlayFilesystem := NewFilesystem("overlay", fstest.MapFS{
|
||||
"overlays/testing.yml": &fstest.MapFile{Data: []byte("items: [{id: overlay}]\n")},
|
||||
})
|
||||
request.overlay = "testing"
|
||||
request.overlayFilesystem = &FilesystemContext{Root: ".", BaseDir: ".", FS: &overlayFilesystem}
|
||||
if got := loadCode(t, func() error { _, err := testGroupedLoad(request); return err }()); got != CodeComposeConflict {
|
||||
t.Fatalf("selected replacement overlay hid lower-tier error: %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replacement overlay and CLI may introduce duplicates", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "items: [{id: base}]\n")
|
||||
writeImportFile(t, directory, "overlays/testing.yml", "items: [{id: duplicate}, {id: duplicate}]\n")
|
||||
result, err := testLoad[config](File(mainPath), Options[config]{Definition: definition, Overlay: "testing"})
|
||||
if err != nil {
|
||||
t.Fatalf("replacement overlay error code = %s", loadCode(t, err))
|
||||
}
|
||||
if len(result.Config.Items) != 2 {
|
||||
t.Fatalf("replacement overlay = %#v, %v", result, err)
|
||||
}
|
||||
|
||||
result, err = testLoad[config](Bytes("main", []byte("items: [{id: base}]\n")), Options[config]{
|
||||
Definition: definition,
|
||||
Overrides: []Override{{Path: Keys("items"), Flag: "--items", Value: []any{map[string]any{"id": "duplicate"}, map[string]any{"id": "duplicate"}}}},
|
||||
})
|
||||
if err != nil || len(result.Config.Items) != 2 {
|
||||
t.Fatalf("replacement CLI value = %#v, %v", result, err)
|
||||
}
|
||||
|
||||
resolved := definition
|
||||
resolved.Resolve = func(value *config, resolution *Resolution) error {
|
||||
value.Items[1].ID = value.Items[0].ID
|
||||
resolution.Record(Keys("items"), ChangeNormalization, "registered identities collide")
|
||||
return nil
|
||||
}
|
||||
result, err = testLoad[config](Bytes("main", []byte("items: [{id: first}, {id: second}]\n")), Options[config]{Definition: resolved})
|
||||
if err != nil {
|
||||
t.Fatalf("resolver replacement error code = %s", loadCode(t, err))
|
||||
}
|
||||
if len(result.Config.Items) != 2 {
|
||||
t.Fatalf("resolver replacement = %#v, %v", result, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("application validation can reject replacement duplicates", func(t *testing.T) {
|
||||
checked := definition
|
||||
checked.Validate = func(value *config, validation *Validation) {
|
||||
seen := make(map[string]bool)
|
||||
for index, entry := range value.Items {
|
||||
if seen[entry.ID] {
|
||||
validation.Error(Keys("items").Index(index).Key("id"), "identity must be unique")
|
||||
}
|
||||
seen[entry.ID] = true
|
||||
}
|
||||
}
|
||||
_, err := testLoad[config](Bytes("main", []byte("items: [{id: base}]\n")), Options[config]{
|
||||
Definition: checked,
|
||||
Overrides: []Override{{Path: Keys("items"), Flag: "--items", Value: []any{map[string]any{"id": "duplicate"}, map[string]any{"id": "duplicate"}}}},
|
||||
})
|
||||
if got := loadCode(t, err); got != CodeValidateError {
|
||||
t.Fatalf("application validation error = %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func mustCompileSequencePolicy(t *testing.T, registrations []sequenceRegistration) sequencePolicy {
|
||||
t.Helper()
|
||||
policy, diagnostics := compileSequencePolicy(registrations)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user