Files
felhom-controller/controller/internal/stacks/metadata_i18n_test.go
T
admin 60f0a86bd4
gates / gates (push) Successful in 25s
v0.257.0: the app catalog can speak English (R-560, slice 5 Part A)
The READ PATH for a second language in `.felhom.yml`. An `i18n: {en: …}` sibling
block inside the same file; `Metadata.For(lang)` merges it FIELD BY FIELD over the
Hungarian, so a missing or blank English field shows the Hungarian one and a
half-translated app is a legal, shippable state.

`For("hu")` is the parsed struct with `I18n` cleared and nothing else — measured
against all 53 real catalog files, copied into `internal/stacks/testdata/catalog/`.
Lists replace whole; every other list is matched by its own key, never by position.
`For` never writes through the receiver: the metadata is the stack manager's, shared
by concurrent requests, and an in-place merge would leak one household's language
into another household's page.

Pages reach catalog copy only through `LocalizeStacks`/`LocalizeStackPtr`/`MetaFor`,
and `TestNoDirectMetaCopyReadOnPages` keeps a named, reasoned allow-list of every
direct `.Meta.<copy>` read in `internal/web` so the NEXT page to read one fails the
suite instead of quietly rendering Hungarian to an English household.

Eight red-proofs. Two of them convicted a hollow TEST rather than the code: a struct
copy shares its slices' backing arrays, so the obvious DeepEqual mutation check
passed a deliberately broken merge; and a one-entry fixture cannot tell key matching
from position matching. Both rewritten, both then seen to fail.

MinAgent: 0.131.0 (unchanged). Older controllers are unaffected — `LoadMetadata`
uses non-strict `yaml.Unmarshal`, so a pre-0.257.0 box drops the whole block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-20 14:31:52 +02:00

479 lines
18 KiB
Go

package stacks
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
// testdata/catalog is a SNAPSHOT of all 53 `templates/<app>/.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)
}
}