package stacks import ( "os" "path/filepath" "reflect" "strings" "testing" "gopkg.in/yaml.v3" ) // testdata/catalog is a SNAPSHOT of all 53 `templates//.felhom.yml` from // app-catalog-felhom.eu at 94bc5febaca2 (2026-09-20), copied verbatim. It is here so the identity // property below is measured on the REAL catalog rather than on a fixture written to pass. // // Refresh it with, from this repo's `controller/` directory: // // for d in ../../app-catalog-felhom.eu/templates/*/; do // a=$(basename "$d"); mkdir -p internal/stacks/testdata/catalog/$a // cp "$d/.felhom.yml" internal/stacks/testdata/catalog/$a/.felhom.yml // done // // A refresh is only ever a copy — if one of these tests then fails, the catalog has grown a shape // the overlay does not handle, which is the finding, not a reason to edit the fixture. const catalogFixtureDir = "testdata/catalog" func loadCatalogFixtures(t *testing.T) map[string]Metadata { t.Helper() entries, err := os.ReadDir(catalogFixtureDir) if err != nil { t.Fatalf("catalog fixtures unreadable: %v", err) } out := make(map[string]Metadata, len(entries)) for _, e := range entries { if !e.IsDir() { continue } b, err := os.ReadFile(filepath.Join(catalogFixtureDir, e.Name(), ".felhom.yml")) if err != nil { t.Fatalf("%s: %v", e.Name(), err) } var m Metadata if err := yaml.Unmarshal(b, &m); err != nil { t.Fatalf("%s: %v", e.Name(), err) } out[e.Name()] = m } if len(out) < 50 { t.Fatalf("only %d catalog fixtures found — the snapshot is not the catalog", len(out)) } return out } // TestMetaForHuIsIdentity — S1. The Hungarian product must render byte-for-byte the same before and // after this slice (10-localisation.md §1). `For("hu")` is therefore required to be the parsed // struct with nothing but `I18n` cleared, over every real catalog file. // // RED-PROOF: mutating any copy field inside For's hu branch — e.g. adding // `out.Description = out.Description + " "` before the `lang == BaseLanguage` return — fails this // naming the app and the field. Run 2026-09-20: FAIL on 53 apps, then green after revert. func TestMetaForHuIsIdentity(t *testing.T) { for app, m := range loadCatalogFixtures(t) { want := m want.I18n = nil for _, lang := range []string{"hu", ""} { got := m.For(lang) if !reflect.DeepEqual(got, want) { t.Errorf("%s: For(%q) changed the Hungarian metadata\n got: %+v\nwant: %+v", app, lang, got, want) } } } } // TestMetaForUnknownLanguageIsHungarian — a language no template carries a block for must fall all // the way back, not render blanks. This is the state EVERY app is in until its batch is pushed. func TestMetaForUnknownLanguageIsHungarian(t *testing.T) { for app, m := range loadCatalogFixtures(t) { want := m want.I18n = nil if got := m.For("en"); !reflect.DeepEqual(got, want) { t.Errorf("%s: For(\"en\") on an untranslated app is not the Hungarian view", app) } } } // TestCatalogFixturesCarryNoOverlayYet pins the fact the two tests above depend on: the snapshot is // of an UNTRANSLATED catalog, so "For(en) equals Hungarian" above is a real assertion and not a // tautology that would keep passing once a block exists. When the pilot lands in the catalog and the // snapshot is refreshed, this test fails — and the refresher must then split the fixture set into // translated and untranslated, rather than delete the assertion. func TestCatalogFixturesCarryNoOverlayYet(t *testing.T) { for app, m := range loadCatalogFixtures(t) { if len(m.I18n) != 0 { t.Errorf("%s: the snapshot now carries an i18n block — see this test's comment", app) } } } // overlayFixture is one hand-written app carrying a PARTIAL English block: `description`, the // tagline, first_steps and ONE of two deploy fields, with the English deploy_fields list in the // OPPOSITE order to the Hungarian one. const overlayFixture = ` display_name: "Fixture" description: "Magyar leírás" slug: fixture app_info: tagline: "Magyar tagline" use_cases: - "Magyar eset egy" - "Magyar eset kettő" first_steps: - "Magyar lépés egy" - "Magyar lépés kettő" prerequisites: - "Magyar előfeltétel" default_creds: "admin / admin" deploy_fields: - env_var: FIRST label: "Első mező" description: "Az első mező leírása" placeholder: "elso" type: text default: "alap" required: true - env_var: SECOND label: "Második mező" description: "A második mező leírása" type: select options: - value: "yes" label: "Igen" - value: "no" label: "Nem" i18n: en: description: "English description" app_info: tagline: "English tagline" first_steps: - "English step one" - "English step two" deploy_fields: - env_var: SECOND label: "Second field" options: - value: "no" label: "No" - env_var: FIRST description: "The first field explained" ` func parseFixture(t *testing.T, src string) Metadata { t.Helper() var m Metadata if err := yaml.Unmarshal([]byte(src), &m); err != nil { t.Fatalf("fixture: %v", err) } return m } // TestMetaForEnFieldByField — S2. Every English field present wins; every absent one shows the // Hungarian; nothing renders blank. func TestMetaForEnFieldByField(t *testing.T) { en := parseFixture(t, overlayFixture).For("en") checks := []struct{ name, got, want string }{ {"description", en.Description, "English description"}, {"tagline", en.AppInfo.Tagline, "English tagline"}, // Absent from the English block — the Hungarian must show through, field by field. {"default_creds", en.AppInfo.DefaultCreds, "admin / admin"}, {"display_name", en.DisplayName, "Fixture"}, } for _, c := range checks { if c.got != c.want { t.Errorf("%s = %q, want %q", c.name, c.got, c.want) } } if got := strings.Join(en.AppInfo.FirstSteps, "|"); got != "English step one|English step two" { t.Errorf("first_steps = %q", got) } // use_cases and prerequisites carry no English — whole Hungarian lists, not empty ones. if got := strings.Join(en.AppInfo.UseCases, "|"); got != "Magyar eset egy|Magyar eset kettő" { t.Errorf("use_cases fell back wrong: %q", got) } if got := strings.Join(en.AppInfo.Prerequisites, "|"); got != "Magyar előfeltétel" { t.Errorf("prerequisites fell back wrong: %q", got) } } // TestDeployFieldsMatchedByKey — S2's second half, and the one that would fail silently on a live // box: the English deploy_fields list is in the OPPOSITE order, so a position match would put // "Second field" on FIRST. Everything that is not copy must also survive untouched. func TestDeployFieldsMatchedByKey(t *testing.T) { en := parseFixture(t, overlayFixture).For("en") byVar := map[string]DeployField{} for _, f := range en.DeployFields { byVar[f.EnvVar] = f } first, second := byVar["FIRST"], byVar["SECOND"] if first.Label != "Első mező" { t.Errorf("FIRST.label = %q — the English block does not translate it", first.Label) } if first.Description != "The first field explained" { t.Errorf("FIRST.description = %q", first.Description) } if first.Placeholder != "elso" { t.Errorf("FIRST.placeholder = %q — no English given, Hungarian expected", first.Placeholder) } if second.Label != "Second field" { t.Errorf("SECOND.label = %q", second.Label) } if second.Description != "A második mező leírása" { t.Errorf("SECOND.description = %q", second.Description) } // Non-copy fields are not in MetadataOverlay at all, and this is the assertion that says so. if first.Default != "alap" || !first.Required || first.Type != "text" { t.Errorf("FIRST lost configuration: %+v", first) } // Options are matched by VALUE, and only the one the block names changes. opts := map[string]string{} for _, o := range second.Options { opts[o.Value] = o.Label } if opts["no"] != "No" { t.Errorf("option no = %q, want %q", opts["no"], "No") } if opts["yes"] != "Igen" { t.Errorf("option yes = %q — untranslated options must stay Hungarian", opts["yes"]) } } // TestEmptyEnglishFallsBack — a translator who leaves a field as `""` must not blank the page. // Operator ruling 3 (10-localisation.md §4): never a key, never an empty box. func TestEmptyEnglishFallsBack(t *testing.T) { m := parseFixture(t, ` description: "Magyar leírás" app_info: tagline: "Magyar tagline" use_cases: ["Magyar eset"] i18n: en: description: "" app_info: tagline: " " use_cases: [] `) en := m.For("en") if en.Description != "Magyar leírás" { t.Errorf("empty English description did not fall back: %q", en.Description) } if en.AppInfo.Tagline != "Magyar tagline" { t.Errorf("whitespace-only English tagline did not fall back: %q", en.AppInfo.Tagline) } if len(en.AppInfo.UseCases) != 1 || en.AppInfo.UseCases[0] != "Magyar eset" { t.Errorf("empty English list did not fall back: %v", en.AppInfo.UseCases) } } // TestForDoesNotMutateTheReceiver — the metadata For reads lives in the stack manager's cache and // is shared by every concurrent request. An in-place write would leak one household's language into // another household's page, and the page would look perfectly normal. // NOTE ON HOW THIS IS ASSERTED. The obvious form — copy the struct, call For, DeepEqual the copy // against the original — IS HOLLOW, and was written that way first: a struct copy shares the slices' // backing arrays, so a write through `out.DeployFields[i].Label` changes BOTH sides and the compare // passes. It did pass, on a deliberately broken For, in this file's red-proof run. What follows // therefore snapshots the concrete STRINGS before the call and compares those. func TestForDoesNotMutateTheReceiver(t *testing.T) { m := parseFixture(t, overlayFixture) snap := func() []string { out := []string{m.Description, m.AppInfo.Tagline, strings.Join(m.AppInfo.FirstSteps, "|")} for _, f := range m.DeployFields { out = append(out, f.EnvVar, f.Label, f.Description, f.Placeholder) for _, o := range f.Options { out = append(out, o.Value, o.Label) } } return out } before := snap() _ = m.For("en") if after := snap(); !reflect.DeepEqual(before, after) { t.Fatalf("For(\"en\") wrote through to the receiver\nbefore: %q\n after: %q", before, after) } // And the hu view taken AFTER an English render is still Hungarian. if hu := m.For("hu"); hu.Description != "Magyar leírás" || hu.DeployFields[1].Options[1].Label != "Nem" { t.Fatalf("Hungarian view damaged by an English render: %q / %q", hu.Description, hu.DeployFields[1].Options[1].Label) } } // TestOverlayMatchesOptionalConfigIntegrationsAndDataPaths covers the three key-matched blocks the // main fixture leaves out. optional_config groups have no id of their own, so the overlay names the // Hungarian group it translates in `match_group`. func TestOverlayMatchesOptionalConfigIntegrationsAndDataPaths(t *testing.T) { // EACH LIST CARRIES TWO ENTRIES AND THE OVERLAY NAMES ONLY THE SECOND. With a single entry per // list — how this fixture was first written — position and key agree, and a position-matching // implementation passes the test. It did, in this file's red-proof run. Two entries, translated // out of order, is what makes the assertion mean anything. m := parseFixture(t, ` optional_config: - group: "Egyéb" description: "Nem fordított csoport" fields: - env_var: OTHER_KEY label: "Más kulcs" help_text: "Más magyar súgó" - group: "Metaadat-szolgáltatók" description: "Magyar csoportleírás" fields: - env_var: KEY_ONE label: "Kulcs egy" help_text: "Magyar súgó" - env_var: KEY_TWO label: "Kulcs kettő" help_text: "Másik magyar súgó" integrations: - target: nextcloud label: "Nem fordított integráció" description: "Nem fordított leírás" - target: filebrowser label: "Magyar integráció" description: "Magyar integrációs leírás" data_paths: - path: "export" role: export label: "Nem fordított mappa" - path: "import" role: import label: "Magyar mappa" initial_credentials: file: /x note: "Magyar megjegyzés" i18n: en: optional_config: - match_group: "Metaadat-szolgáltatók" group: "Metadata providers" fields: - env_var: KEY_TWO help_text: "English help" integrations: - target: filebrowser label: "English integration" data_paths: - path: "import" label: "English folder" initial_credentials: note: "English note" `) en := m.For("en") // The FIRST entry of each list is the one nobody translated — it must be untouched. if en.OptionalConfig[0].Group != "Egyéb" || en.OptionalConfig[0].Fields[0].HelpText != "Más magyar súgó" { t.Errorf("an untranslated group was changed: %+v", en.OptionalConfig[0]) } if en.Integrations[0].Label != "Nem fordított integráció" { t.Errorf("an untranslated integration was changed: %+v", en.Integrations[0]) } if en.DataPaths[0].Label != "Nem fordított mappa" { t.Errorf("an untranslated data_path was changed: %+v", en.DataPaths[0]) } g := en.OptionalConfig[1] if g.Group != "Metadata providers" { t.Errorf("group = %q", g.Group) } if g.Description != "Magyar csoportleírás" { t.Errorf("group description should fall back: %q", g.Description) } if g.Fields[1].HelpText != "English help" { t.Errorf("KEY_TWO help_text = %q", g.Fields[1].HelpText) } if g.Fields[0].HelpText != "Magyar súgó" || g.Fields[1].Label != "Kulcs kettő" { t.Errorf("unnamed optional-config fields must stay Hungarian: %+v", g.Fields) } if en.Integrations[1].Label != "English integration" || en.Integrations[1].Description != "Magyar integrációs leírás" { t.Errorf("integration overlay wrong: %+v", en.Integrations[1]) } if en.DataPaths[1].Label != "English folder" || en.DataPaths[1].Path != "import" { t.Errorf("data_path overlay wrong: %+v", en.DataPaths[1]) } if en.InitialCreds.Note != "English note" || en.InitialCreds.File != "/x" { t.Errorf("initial_credentials overlay wrong: %+v", en.InitialCreds) } // The Hungarian view is still Hungarian afterwards — the nested slices were copied, not edited. hu := m.For("hu") if hu.OptionalConfig[1].Group != "Metaadat-szolgáltatók" || hu.OptionalConfig[1].Fields[1].HelpText != "Másik magyar súgó" { t.Errorf("Hungarian optional_config damaged: %+v", hu.OptionalConfig[1]) } if hu.InitialCreds.Note != "Magyar megjegyzés" { t.Errorf("Hungarian initial_credentials note damaged: %q", hu.InitialCreds.Note) } } // TestUnmatchedOverlayEntriesAreInert — an English entry whose key has no Hungarian twin must // change nothing. (The catalog gate REFUSES such a file; this is the controller's behaviour if one // reaches a box anyway, e.g. from a hand-edited template.) func TestUnmatchedOverlayEntriesAreInert(t *testing.T) { m := parseFixture(t, ` deploy_fields: - env_var: FIRST label: "Első mező" i18n: en: deploy_fields: - env_var: NOSUCHFIELD label: "Ghost" `) en := m.For("en") if len(en.DeployFields) != 1 || en.DeployFields[0].Label != "Első mező" { t.Fatalf("an unmatched overlay entry changed the fields: %+v", en.DeployFields) } } // TestLoadMetadataReadsTheI18nBlock — the loader is the seam that actually has to see `i18n:`. // Without this the whole overlay could be correct and inert (the "seam built but never wired" // class, on file four times in this project). func TestLoadMetadataReadsTheI18nBlock(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(overlayFixture), 0o644); err != nil { t.Fatal(err) } m := LoadMetadata(dir) if len(m.I18n) != 1 { t.Fatalf("LoadMetadata did not parse the i18n block: %+v", m.I18n) } if got := m.For("en").Description; got != "English description" { t.Fatalf("loaded metadata does not localise: %q", got) } if got := m.For("hu").Description; got != "Magyar leírás" { t.Fatalf("loaded metadata changed Hungarian: %q", got) } } // TestLocalizeStacksLeavesHungarianSliceAlone — LocalizeStacks returns the INPUT SLICE for hu. That // is the parity guarantee in code, so it is asserted rather than assumed. func TestLocalizeStacksLeavesHungarianSliceAlone(t *testing.T) { in := []Stack{{Name: "a", Meta: parseFixture(t, overlayFixture)}} if got := LocalizeStacks(in, "hu"); &got[0] != &in[0] { t.Fatalf("LocalizeStacks copied the slice for hu") } out := LocalizeStacks(in, "en") if out[0].Meta.Description != "English description" { t.Fatalf("LocalizeStacks did not localise: %q", out[0].Meta.Description) } if in[0].Meta.Description != "Magyar leírás" { t.Fatalf("LocalizeStacks wrote through to the input: %q", in[0].Meta.Description) } if out[0].Meta.I18n != nil { t.Fatalf("the untranslated half must not reach a page") } } // TestLocalizeStackPtrReturnsANewPointer — the manager hands out pointers to its own values; a // localised page must never be able to write into one. func TestLocalizeStackPtrReturnsANewPointer(t *testing.T) { st := &Stack{Name: "a", Meta: parseFixture(t, overlayFixture)} out := LocalizeStackPtr(st, "en") if out == st { t.Fatalf("LocalizeStackPtr returned the caller's pointer") } if st.Meta.Description != "Magyar leírás" { t.Fatalf("the manager's value was localised in place: %q", st.Meta.Description) } if LocalizeStackPtr(nil, "en") != nil { t.Fatalf("nil must stay nil") } if got := LocalizeStackPtr(st, "hu"); got.Meta.Description != "Magyar leírás" { t.Fatalf("hu view wrong: %q", got.Meta.Description) } if got := st.MetaFor("en"); got.Description != "English description" { t.Fatalf("MetaFor wrong: %q", got.Description) } }