Schedule underlay groups as dependency forests

This commit is contained in:
2026-10-19 19:53:01 +00:00
parent 263dd2f1bd
commit 014b99504b
7 changed files with 437 additions and 34 deletions

View File

@@ -19,6 +19,186 @@ func testGroupedLoad[T any](request loadRequest[T]) (*testResult[T], error) {
return testLoaded(loaded), err
}
func TestUnderlayGroupSchedulesInterleavedImportBranches(t *testing.T) {
directory := t.TempDir()
zRoot := writeImportFile(t, directory, "z-root.yml", `configkit:
imports:
- path: d-parent.yml
items: [{id: z}]
`)
dParent := writeImportFile(t, directory, "d-parent.yml", `configkit:
imports:
- path: a-dependency.yml
items: [{id: d}]
`)
aDependency := writeImportFile(t, directory, "a-dependency.yml", "items: [{id: a}]\n")
bRoot := writeImportFile(t, directory, "b-root.yml", `configkit:
imports:
- path: c-helper.yml
items: [{id: b}]
`)
cHelper := writeImportFile(t, directory, "c-helper.yml", "items: [{id: c}]\n")
request := loadRequest[map[string]any]{
main: loadInput{source: NoSource()},
underlays: []loadGroup{{inputs: []loadInput{
{source: NoSource()},
{source: File(zRoot), imports: true},
{source: File(bRoot), imports: true},
}}},
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
}
result, err := testGroupedLoad(request)
if err != nil {
t.Fatalf("grouped load: %v", err)
}
items := result.Config["items"].([]any)
wantIDs := []string{"a", "c", "b", "d", "z"}
if len(items) != len(wantIDs) {
t.Fatalf("scheduled items = %#v", items)
}
for index, want := range wantIDs {
if got := items[index].(map[string]any)["id"]; got != want {
t.Errorf("scheduled item %d ID = %#v, want %q", index, got, want)
}
}
wantPaths := []string{zRoot, dParent, aDependency, bRoot, cHelper}
for index, want := range wantPaths {
if got := result.Sources()[index].Path; got != canonicalTestPath(t, want) {
t.Errorf("acquired source %d path = %q, want %q", index, got, canonicalTestPath(t, want))
}
}
if got := result.Sources(); len(got) != len(wantPaths) {
t.Fatalf("NoSource added a source record: %#v", got)
}
}
func TestUnderlayNamespaceAnchorsUseInputPositionsAndFilesystemIdentity(t *testing.T) {
firstNamespace := NewFilesystem("same-label", fstest.MapFS{
"root.yml": &fstest.MapFile{Data: []byte("items: [{id: first}]\n")},
"later.yml": &fstest.MapFile{Data: []byte("items: [{id: copied}]\n")},
})
secondNamespace := NewFilesystem("same-label", fstest.MapFS{
"root.yml": &fstest.MapFile{Data: []byte("items: [{id: second}]\n")},
})
request := loadRequest[map[string]any]{
main: loadInput{source: NoSource()},
underlays: []loadGroup{{inputs: []loadInput{
{source: firstNamespace.File("root.yml")},
{source: secondNamespace.File("root.yml")},
{source: firstNamespace.File("later.yml")},
}}},
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
}
result, err := testGroupedLoad(request)
if err != nil {
t.Fatalf("grouped load with native namespaces: %v", err)
}
items := result.Config["items"].([]any)
wantIDs := []string{"copied", "first", "second"}
if len(items) != len(wantIDs) {
t.Fatalf("scheduled native items = %#v", items)
}
for index, want := range wantIDs {
if got := items[index].(map[string]any)["id"]; got != want {
t.Errorf("scheduled native item %d ID = %#v, want %q", index, got, want)
}
}
if sources := result.Sources(); len(sources) != len(wantIDs) {
t.Fatalf("source records = %#v", sources)
}
}
func TestUnderlayItemConflictUsesScheduledGroupIndex(t *testing.T) {
directory := t.TempDir()
zItem := writeImportFile(t, directory, "z-item.yml", "id: duplicate\n")
aItem := writeImportFile(t, directory, "a-item.yml", "id: duplicate\n")
request := loadRequest[map[string]any]{
main: loadInput{source: NoSource()},
underlays: []loadGroup{{inputs: []loadInput{
{source: File(zItem), destination: Keys("items"), placementMode: placementModeSequenceItem},
{source: File(aItem), destination: Keys("items"), placementMode: placementModeSequenceItem},
}}},
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
}
_, err := testGroupedLoad(request)
loadError, ok := err.(*LoadError)
if !ok || len(loadError.Diagnostics()) != 1 {
t.Fatalf("expected item conflict: %v", err)
}
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Code != CodeComposeConflict || diagnostic.Path.String() != "/items" {
t.Fatalf("group item conflict = %s %s, want compose.conflict /items", diagnostic.Code, diagnostic.Path.String())
}
files := make(map[string]string)
for _, source := range loadError.Sources() {
files[source.ID] = source.Path
}
locations := append([]Location(nil), diagnostic.Related...)
if diagnostic.Primary != nil {
locations = append(locations, *diagnostic.Primary)
}
wantLocations := map[string]Location{
canonicalTestPath(t, zItem): {Line: 1, Column: 5},
canonicalTestPath(t, aItem): {Line: 1, Column: 5},
}
gotPaths := make(map[string]bool, len(locations))
for _, location := range locations {
want := wantLocations[files[location.SourceID]]
gotPaths[files[location.SourceID]] = location.Line == want.Line && location.Column == want.Column
}
if len(locations) != 2 || !gotPaths[canonicalTestPath(t, zItem)] || !gotPaths[canonicalTestPath(t, aItem)] {
t.Fatalf("group item conflict sites = %#v; source paths = %#v", locations, files)
}
}
func TestUnderlayItemHelperConflictUsesScheduledGroupIndex(t *testing.T) {
directory := t.TempDir()
zBad := writeImportFile(t, directory, "z-bad.yml", "configkit: {imports: [{path: helper.yml}]}\nid: duplicate\n")
helper := writeImportFile(t, directory, "helper.yml", "id: duplicate\n")
aGood := writeImportFile(t, directory, "a-good.yml", "id: good\n")
request := loadRequest[map[string]any]{
main: loadInput{source: NoSource()},
underlays: []loadGroup{{inputs: []loadInput{
{source: File(zBad), imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem},
{source: File(aGood), destination: Keys("items"), placementMode: placementModeSequenceItem},
}}},
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
}
_, err := testGroupedLoad(request)
loadError, ok := err.(*LoadError)
if !ok || len(loadError.Diagnostics()) != 1 {
t.Fatalf("expected item helper conflict: %v", err)
}
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Code != CodeComposeConflict || diagnostic.Path.String() != "/items/1/id" {
t.Fatalf("group item helper conflict = %s %s, want compose.conflict /items/1/id", diagnostic.Code, diagnostic.Path.String())
}
files := make(map[string]string)
for _, source := range loadError.Sources() {
files[source.ID] = source.Path
}
if len(files) != 3 {
t.Fatalf("group acquisition stopped before all sources were read: %#v", files)
}
locations := append([]Location(nil), diagnostic.Related...)
if diagnostic.Primary != nil {
locations = append(locations, *diagnostic.Primary)
}
wantLocations := map[string]Location{
canonicalTestPath(t, zBad): {Line: 2, Column: 5},
canonicalTestPath(t, helper): {Line: 1, Column: 5},
}
gotPaths := make(map[string]bool, len(locations))
for _, location := range locations {
want := wantLocations[files[location.SourceID]]
gotPaths[files[location.SourceID]] = location.Line == want.Line && location.Column == want.Column
}
if len(locations) != 2 || !gotPaths[canonicalTestPath(t, zBad)] || !gotPaths[canonicalTestPath(t, helper)] {
t.Fatalf("group item helper conflict sites = %#v; source paths = %#v", locations, files)
}
}
func TestGroupedLoadPrecedenceSourceOrderAndSingleLifecycle(t *testing.T) {
filesystem := NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: imported.yml}]}\nversion: v1\nport: 5\nsettings: {shared: main}\n")},