Assemble imported sequence-item scopes
This commit is contained in:
@@ -9,6 +9,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
type importTestConfig struct {
|
||||
@@ -155,6 +157,8 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
|
||||
{name: "two targets", body: "configkit: {imports: [{path: a.yml, glob: '*.yml'}]}\n", code: CodeImportInvalid},
|
||||
{name: "unknown declaration field", body: "configkit: {imports: [{path: a.yml, extra: true}]}\n", code: CodeImportInvalid},
|
||||
{name: "optional kind", body: "configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid},
|
||||
{name: "placement kind", body: "configkit: {imports: [{path: a.yml, placement: true}]}\n", code: CodeImportInvalid},
|
||||
{name: "placement value", body: "configkit: {imports: [{path: a.yml, placement: item}]}\n", code: CodeImportInvalid},
|
||||
{name: "nested directive", body: "nested: {configkit: {}}\n", code: CodeImportInvalid},
|
||||
}
|
||||
for _, test := range tests {
|
||||
@@ -166,6 +170,322 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadItemImportComposesHelpersAndNestedItemsInDeclaringScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
type nested struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
type record struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Label string `yaml:"label"`
|
||||
Metadata map[string]string `yaml:"metadata"`
|
||||
Children []nested `yaml:"children"`
|
||||
}
|
||||
type config struct {
|
||||
Items []record `yaml:"items"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: records/item.yml
|
||||
destination: [items]
|
||||
placement: sequence-item
|
||||
`)
|
||||
itemPath := writeImportFile(t, directory, "records/item.yml", `configkit:
|
||||
imports:
|
||||
- path: nested/helper.yml
|
||||
- path: child.yml
|
||||
destination: [children]
|
||||
placement: sequence-item
|
||||
name: authored
|
||||
`)
|
||||
helperPath := writeImportFile(t, directory, "records/nested/helper.yml", `configkit:
|
||||
imports:
|
||||
- path: more.yml
|
||||
destination: [metadata]
|
||||
id: supplied
|
||||
label: helper
|
||||
`)
|
||||
morePath := writeImportFile(t, directory, "records/nested/more.yml", "flavor: transitive\n")
|
||||
childPath := writeImportFile(t, directory, "records/child.yml", "value: nested\n")
|
||||
|
||||
result, err := testLoad[config](File(mainPath), Options[config]{Imports: true})
|
||||
if err != nil {
|
||||
t.Fatalf("load item import: %v", err)
|
||||
}
|
||||
if len(result.Config.Items) != 1 {
|
||||
t.Fatalf("items = %#v", result.Config.Items)
|
||||
}
|
||||
item := result.Config.Items[0]
|
||||
if item.ID != "supplied" || item.Name != "authored" || item.Label != "helper" || item.Metadata["flavor"] != "transitive" || len(item.Children) != 1 || item.Children[0].Value != "nested" {
|
||||
t.Fatalf("composed item = %#v", item)
|
||||
}
|
||||
if got := result.Sources(); len(got) != 5 || got[1].Path != canonicalTestPath(t, itemPath) || got[2].Path != canonicalTestPath(t, helperPath) || got[3].Path != canonicalTestPath(t, morePath) || got[4].Path != canonicalTestPath(t, childPath) {
|
||||
t.Fatalf("source traversal = %#v", got)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
index int
|
||||
want string
|
||||
}{
|
||||
{index: 1, want: "/items"},
|
||||
{index: 2, want: "/items/0"},
|
||||
{index: 3, want: "/items/0/metadata"},
|
||||
{index: 4, want: "/items/0/children"},
|
||||
} {
|
||||
if got := result.Sources()[test.index].Destination.String(); got != test.want {
|
||||
t.Errorf("source %d destination = %q, want %q", test.index+1, got, test.want)
|
||||
}
|
||||
}
|
||||
for _, test := range []struct {
|
||||
path Path
|
||||
sourceID string
|
||||
}{
|
||||
{path: Keys("items").Index(0).Key("id"), sourceID: "source-3"},
|
||||
{path: Keys("items").Index(0).Key("metadata").Key("flavor"), sourceID: "source-4"},
|
||||
} {
|
||||
info, ok := result.Metadata().Lookup(test.path)
|
||||
if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != test.sourceID {
|
||||
t.Errorf("origin at %s = %#v, %v, want source %s", test.path.String(), info, ok, test.sourceID)
|
||||
}
|
||||
foundInHistory := false
|
||||
for _, change := range result.Metadata().History(test.path) {
|
||||
for _, location := range change.Origin.Locations {
|
||||
foundInHistory = foundInHistory || location.SourceID == test.sourceID
|
||||
}
|
||||
}
|
||||
if !foundInHistory {
|
||||
t.Errorf("history at %s does not retain source %s", test.path.String(), test.sourceID)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("duplicate helper identity remains a mapping conflict", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: item.yml
|
||||
destination: [items]
|
||||
placement: sequence-item
|
||||
`)
|
||||
writeImportFile(t, directory, "item.yml", `configkit:
|
||||
imports:
|
||||
- path: helper.yml
|
||||
id: authored
|
||||
`)
|
||||
writeImportFile(t, directory, "helper.yml", "id: supplied\n")
|
||||
_, err := testLoad[config](File(mainPath), Options[config]{Imports: true})
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 {
|
||||
t.Fatalf("duplicate identity error = %v", err)
|
||||
}
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Code != CodeComposeConflict || diagnostic.Path.String() != "/items/0/id" || diagnostic.Primary == nil || len(diagnostic.Related) != 1 {
|
||||
t.Fatalf("duplicate identity diagnostic = %#v", diagnostic)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLayeredUnderlayItemImportRetainsNestedEvidenceAndDestinations(t *testing.T) {
|
||||
t.Parallel()
|
||||
type nested struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
type record struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Label string `yaml:"label"`
|
||||
Metadata map[string]string `yaml:"metadata"`
|
||||
Children []nested `yaml:"children"`
|
||||
}
|
||||
type config struct {
|
||||
Catalog struct {
|
||||
Entries []record `yaml:"entries"`
|
||||
} `yaml:"catalog"`
|
||||
}
|
||||
|
||||
directory := t.TempDir()
|
||||
itemPath := writeImportFile(t, directory, "records/item.yml", `configkit:
|
||||
imports:
|
||||
- path: nested/helper.yml
|
||||
- path: children/child.yml
|
||||
destination: [children]
|
||||
placement: sequence-item
|
||||
name: authored
|
||||
`)
|
||||
helperPath := writeImportFile(t, directory, "records/nested/helper.yml", `configkit:
|
||||
imports:
|
||||
- path: more.yml
|
||||
destination: [metadata]
|
||||
id: supplied
|
||||
label: helper
|
||||
`)
|
||||
morePath := writeImportFile(t, directory, "records/nested/more.yml", "flavor: transitive\n")
|
||||
childPath := writeImportFile(t, directory, "records/children/child.yml", "value: nested\n")
|
||||
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: NoSource()}, testSecretBinding(),
|
||||
LayeredOptions[config]{Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: File(itemPath), Imports: true, Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem,
|
||||
}}}}},
|
||||
)
|
||||
if err != nil {
|
||||
if loadError, ok := err.(*LoadError); ok {
|
||||
t.Fatalf("LoadLayered: %v, diagnostics %#v", err, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if len(result.Config.Catalog.Entries) != 1 || result.Config.Catalog.Entries[0].ID != "supplied" || result.Config.Catalog.Entries[0].Name != "authored" ||
|
||||
result.Config.Catalog.Entries[0].Label != "helper" || result.Config.Catalog.Entries[0].Metadata["flavor"] != "transitive" ||
|
||||
len(result.Config.Catalog.Entries[0].Children) != 1 || result.Config.Catalog.Entries[0].Children[0].Value != "nested" {
|
||||
t.Fatalf("underlay item = %#v", result.Config.Catalog.Entries)
|
||||
}
|
||||
|
||||
sources := result.Sources()
|
||||
wantSources := []struct {
|
||||
path string
|
||||
destination string
|
||||
}{
|
||||
{path: itemPath, destination: "/catalog/entries"},
|
||||
{path: helperPath, destination: "/catalog/entries/0"},
|
||||
{path: morePath, destination: "/catalog/entries/0/metadata"},
|
||||
{path: childPath, destination: "/catalog/entries/0/children"},
|
||||
}
|
||||
if len(sources) != len(wantSources) {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
for index, want := range wantSources {
|
||||
if sources[index].Path != canonicalTestPath(t, want.path) || sources[index].Destination.String() != want.destination {
|
||||
t.Errorf("source %d = %#v, want path %q and destination %q", index+1, sources[index], want.path, want.destination)
|
||||
}
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
path Path
|
||||
sourceID string
|
||||
}{
|
||||
{path: Keys("catalog", "entries").Index(0).Key("id"), sourceID: "source-2"},
|
||||
{path: Keys("catalog", "entries").Index(0).Key("metadata").Key("flavor"), sourceID: "source-3"},
|
||||
{path: Keys("catalog", "entries").Index(0).Key("children").Index(0).Key("value"), sourceID: "source-4"},
|
||||
} {
|
||||
info, ok := result.Metadata().Lookup(test.path)
|
||||
if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != test.sourceID || info.Origin.Layer != LayerUnderlay {
|
||||
t.Errorf("origin at %s = %#v, %v, want %s in underlay", test.path.String(), info, ok, test.sourceID)
|
||||
}
|
||||
foundInHistory := false
|
||||
for _, change := range result.Metadata().History(test.path) {
|
||||
for _, location := range change.Origin.Locations {
|
||||
foundInHistory = foundInHistory || location.SourceID == test.sourceID
|
||||
}
|
||||
}
|
||||
if !foundInHistory {
|
||||
t.Errorf("history at %s does not retain source %s", test.path.String(), test.sourceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadItemGlobKeepsDefaultModeIndependentAndOptionalMissesAbsent(t *testing.T) {
|
||||
t.Parallel()
|
||||
type item struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
type config struct {
|
||||
Items []item `yaml:"items"`
|
||||
Other map[string]string `yaml:"other"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- glob: records/*.yml
|
||||
destination: [items]
|
||||
placement: sequence-item
|
||||
- path: absent/*.yml
|
||||
destination: [items]
|
||||
optional: true
|
||||
placement: sequence-item
|
||||
- path: other.yml
|
||||
destination: [other]
|
||||
`)
|
||||
emptyPath := writeImportFile(t, directory, "records/empty.yml", "{}\n")
|
||||
writeImportFile(t, directory, "other.yml", "value: mapping-default\n")
|
||||
result, err := testLoad[config](File(mainPath), Options[config]{Imports: true})
|
||||
if err != nil {
|
||||
t.Fatalf("load item glob: %v", err)
|
||||
}
|
||||
if len(result.Config.Items) != 1 || result.Config.Items[0].Value != "" || result.Config.Other["value"] != "mapping-default" {
|
||||
t.Fatalf("config = %#v", result.Config)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 3 || sources[1].Path != canonicalTestPath(t, emptyPath) || sources[1].Destination.String() != "/items" || sources[1].placementMode != placementModeSequenceItem || sources[2].placementMode != placementModeMapping || sources[2].Destination.String() != "/other" {
|
||||
t.Fatalf("source placement evidence = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMainItemPlacementKeepsItsHelpersInTheItem(t *testing.T) {
|
||||
t.Parallel()
|
||||
type item struct {
|
||||
ID string `yaml:"id"`
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
type config struct {
|
||||
Items []item `yaml:"items"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: helper.yml\nid: main\n")
|
||||
helperPath := writeImportFile(t, directory, "helper.yml", "value: helper\n")
|
||||
result, err := testLoad[config](File(mainPath), Options[config]{
|
||||
Imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load main item: %v", err)
|
||||
}
|
||||
if len(result.Config.Items) != 1 || result.Config.Items[0].ID != "main" || result.Config.Items[0].Value != "helper" {
|
||||
t.Fatalf("main item = %#v", result.Config.Items)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].placementMode != placementModeSequenceItem || sources[1].Destination.String() != "/items/0" || sources[1].placementMode != placementModeMapping {
|
||||
t.Fatalf("main item sources = %#v", sources)
|
||||
}
|
||||
if sources[1].Path != canonicalTestPath(t, helperPath) {
|
||||
t.Fatalf("helper path = %q", sources[1].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemImportCanJoinAnAuthoredListUnderRegisteredComposition(t *testing.T) {
|
||||
t.Parallel()
|
||||
type config struct {
|
||||
Items []map[string]string `yaml:"items"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: item.yml
|
||||
destination: [items]
|
||||
placement: sequence-item
|
||||
`)
|
||||
writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: helper.yml}]}\nname: imported\n")
|
||||
writeImportFile(t, directory, "helper.yml", "id: helper-id\n")
|
||||
input, diagnostics := assembleBaseInput(File(mainPath), Options[config]{Imports: true})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("assemble item import: %#v", diagnostics)
|
||||
}
|
||||
authored := parseDocumentForDecode(t, "items: [{id: authored-id, name: authored}]\n")
|
||||
composed, diagnostics := composeDocumentsWithPolicy(sequencePolicy{identityFields: map[string]string{"/items": "id"}}, authored, input.document)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("compose authored and imported lists: %#v", diagnostics)
|
||||
}
|
||||
items, ok := documentAtPath(composed.root, Keys("items"))
|
||||
if !ok || items.Kind != document.Sequence || len(items.Sequence) != 2 {
|
||||
t.Fatalf("composed items = %#v", items)
|
||||
}
|
||||
if id, ok := documentAtPath(composed.root, Keys("items").Index(1).Key("id")); !ok || id.Text != "helper-id" {
|
||||
t.Fatalf("imported helper ID = %#v, %v", id, ok)
|
||||
}
|
||||
if origin := composed.origins[Keys("items").Index(1).Key("id").String()]; len(origin.Locations) != 1 || origin.Locations[0].SourceID != "source-3" {
|
||||
t.Fatalf("registered composition origin = %#v", origin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImportDestinationUsesLiteralStringSegments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -201,6 +521,19 @@ func TestParseImportDestinationUsesLiteralStringSegments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImportPlacementDefaultsPerDeclaration(t *testing.T) {
|
||||
t.Parallel()
|
||||
declarations := parseImportDeclarationsYAML(t, `configkit:
|
||||
imports:
|
||||
- path: mapping.yml
|
||||
- glob: records/*.yml
|
||||
placement: sequence-item
|
||||
`)
|
||||
if len(declarations) != 2 || declarations[0].placement != placementModeMapping || declarations[1].placement != placementModeSequenceItem {
|
||||
t.Fatalf("declaration placements = %#v", declarations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImportDestinationRejectsInvalidShapesAndMembers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user