fix(14): WR-05 import every key of an object-shaped models repeater

A repeater saved with non-sequential keys was read only at keys 0 to
len-1, so a model at a higher key was dropped while the import reported
success and refused to run again. Every key is now read, numeric keys in
ascending order.
This commit is contained in:
Jakub Zych
2026-10-04 08:08:44 +02:00
parent 93b2b05476
commit 9f60b6138c
2 changed files with 44 additions and 5 deletions

View File

@@ -2,12 +2,15 @@
package console
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"math"
"os"
"slices"
"strconv"
"strings"
@@ -150,11 +153,11 @@ func repeaterItems(raw []byte) ([]map[string]any, error) {
case []any:
list = t
case map[string]any:
// A repeater saved with non-sequential keys decodes as an object.
for i := 0; i < len(t); i++ {
if item, ok := t[strconv.Itoa(i)]; ok {
list = append(list, item)
}
// A repeater saved with non-sequential keys decodes as an object
// ({"0":…,"2":…} after a middle item was deleted). Every key is
// read, numeric keys in ascending order, then any other key.
for _, k := range repeaterKeys(t) {
list = append(list, t[k])
}
case nil:
default:
@@ -169,6 +172,26 @@ func repeaterItems(raw []byte) ([]map[string]any, error) {
return out, nil
}
// repeaterKeys orders an object repeater's keys: integers ascending by
// value first, then the other keys in byte order.
func repeaterKeys(t map[string]any) []string {
keys := slices.Collect(maps.Keys(t))
slices.SortFunc(keys, func(a, b string) int {
ai, aerr := strconv.Atoi(a)
bi, berr := strconv.Atoi(b)
switch {
case aerr == nil && berr == nil:
return cmp.Compare(ai, bi)
case aerr == nil:
return -1
case berr == nil:
return 1
}
return strings.Compare(a, b)
})
return keys
}
func modelFromItem(item map[string]any) models.AiModel {
row := models.AiModel{
Name: str(item["name"]),

View File

@@ -151,6 +151,22 @@ func TestGolemImportCommand(t *testing.T) {
if err := db.Exec(`DELETE FROM golem15_golem_models`).Error; err != nil {
t.Fatal(err)
}
// A repeater whose middle item was deleted keeps every remaining key,
// in numeric order (WR-05).
sparse := write("sparse.json", `{"models":{"10":{"name":"Ten","model":"m10","api_key":"k10"},"0":{"name":"Zero","model":"m0","api_key":"k0"},"2":{"name":"Two","model":"m2","api_key":"k2"}}}`)
if out, err := run(app, sparse); err != nil || !strings.Contains(out, "Imported 3 AI models.") {
t.Fatalf("sparse import = %q %v", out, err)
}
rows = nil
if err := db.Order("sort_order").Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 3 || rows[0].Name != "Zero" || rows[1].Name != "Two" || rows[2].Name != "Ten" {
t.Fatalf("sparse rows = %+v", rows)
}
if err := db.Exec(`DELETE FROM golem15_golem_models`).Error; err != nil {
t.Fatal(err)
}
for name, body := range map[string]string{"empty.json": `{"models":[]}`, "null.json": `{"models":null}`} {
if out, err := run(app, write(name, body)); err != nil || !strings.Contains(out, "no AI models") {
t.Errorf("%s = %q %v", name, out, err)