package stacks import ( "reflect" "strings" "testing" "gopkg.in/yaml.v3" ) // THE GUIDE SCHEMA (v0.315.0) — parsing, the render gate, the English overlay, and the address. // // The guide is the first catalog block whose list entries are matched BY INDEX (AppGuideOverlay says // why). These tests pin the three properties that makes safe: the base language is untouched, every // field falls back on its own, and an oversized translation is dropped rather than rendered. // guideYAML is one template carrying a guide and an English overlay of the FAQ ONLY — the // half-translated state the catalog is allowed to ship (10-localisation.md §7). const guideYAML = ` name: testapp display_name: TestApp description: "Egy teszt alkalmazas" app_info: tagline: "Teszt" guide: checked_version: "1.2.3" sections: - title: "Elso bekapcsolas" intro: "Nyisd meg a wiki.DOMAIN oldalt." steps: ["Kattints a Fiok menure", "Add meg a DOMAIN cimet"] - title: "Megosztas" steps: ["Valaszd ki a mappat"] faq: - q: "Hol vannak a fajlok?" a: "A megadott mappaban." - q: "Lehet tobb felhasznalo?" a: "Igen." - q: "Mi van ha elfelejtem a jelszot?" a: "Kerj ujat." i18n: en: app_info: guide: faq: - q: "Where are the files?" a: "In the folder you chose." - q: "Can several people use it?" a: "Yes." - q: "What if I forget the password?" a: "Ask for a new one." ` func mustGuideMeta(t *testing.T, src string) Metadata { t.Helper() var m Metadata if err := yaml.Unmarshal([]byte(src), &m); err != nil { t.Fatalf("parse: %v", err) } return m } // TestGuide_Parses — the block reaches the struct with its version, both sections and all three FAQ // entries. Without this the three tests below could pass on an empty guide. func TestGuide_Parses(t *testing.T) { g := mustGuideMeta(t, guideYAML).AppInfo.Guide if g.CheckedVersion != "1.2.3" { t.Errorf("checked_version = %q, want 1.2.3", g.CheckedVersion) } if len(g.Sections) != 2 || len(g.FAQ) != 3 { t.Fatalf("got %d sections and %d faq, want 2 and 3", len(g.Sections), len(g.FAQ)) } if g.Sections[0].Title != "Elso bekapcsolas" || len(g.Sections[0].Steps) != 2 { t.Errorf("section 0 = %+v", g.Sections[0]) } if g.Sections[1].Intro != "" { t.Errorf("section 1 intro should be absent, got %q", g.Sections[1].Intro) } } // TestGuide_RenderedGate — `checked_version` is the gate, and it is ALSO required that there be // something to show. Both halves are asserted here because the template asks this one method. func TestGuide_RenderedGate(t *testing.T) { full := mustGuideMeta(t, guideYAML).AppInfo.Guide if !full.Rendered() { t.Error("a checked guide with sections and faq must render") } noVersion := full noVersion.CheckedVersion = "" if noVersion.Rendered() { t.Error("a guide with NO checked_version must not render — nobody vouched for it") } blankVersion := full blankVersion.CheckedVersion = " " if blankVersion.Rendered() { t.Error("a whitespace-only checked_version is absent, not a version") } empty := AppGuide{CheckedVersion: "1.2.3"} if empty.Rendered() { t.Error("a checked guide with no sections and no faq has nothing to render") } if (AppGuide{}).Rendered() { t.Error("an absent guide must not render") } } // TestGuide_OverlayIsFieldByField — English shows the English FAQ and keeps the Hungarian sections, // because this template translated only the FAQ. That is the half-translated state §7 permits, and // the per-field fallback is what makes it legal. func TestGuide_OverlayIsFieldByField(t *testing.T) { m := mustGuideMeta(t, guideYAML) en := m.For("en").AppInfo.Guide if en.FAQ[0].Q != "Where are the files?" || en.FAQ[2].A != "Ask for a new one." { t.Errorf("the English FAQ did not reach the page: %+v", en.FAQ) } if en.Sections[0].Title != "Elso bekapcsolas" { t.Errorf("an untranslated section must fall back to Hungarian, got %q", en.Sections[0].Title) } // The gate is not copy: a translation can neither set nor clear it. if en.CheckedVersion != "1.2.3" { t.Errorf("checked_version = %q after overlay, want it carried through", en.CheckedVersion) } // Hungarian is untouched by the presence of the block. hu := m.For("hu").AppInfo.Guide if hu.FAQ[0].Q != "Hol vannak a fajlok?" { t.Errorf("the Hungarian FAQ changed: %q", hu.FAQ[0].Q) } // ...and the overlay did not write through into the receiver (shared metadata, R-591). if m.AppInfo.Guide.FAQ[0].Q != "Hol vannak a fajlok?" { t.Error("For() mutated the receiver's guide") } } // TestGuide_OverlayPartialFields — one section translated, its intro left blank. A blank English // string is ABSENT (operator ruling 3), so the Hungarian intro stays and no empty box renders. func TestGuide_OverlayPartialFields(t *testing.T) { src := strings.Replace(guideYAML, ` guide: faq:`, ` guide: sections: - title: "First start" intro: "" steps: ["Open the Account menu", "Enter the DOMAIN address"] faq:`, 1) en := mustGuideMeta(t, src).For("en").AppInfo.Guide if en.Sections[0].Title != "First start" { t.Errorf("section 0 title = %q, want the English one", en.Sections[0].Title) } if en.Sections[0].Intro != "Nyisd meg a wiki.DOMAIN oldalt." { t.Errorf("a BLANK English intro must fall back to Hungarian, got %q", en.Sections[0].Intro) } if en.Sections[0].Steps[0] != "Open the Account menu" { t.Errorf("section 0 steps = %v", en.Sections[0].Steps) } // Section 1 had no English entry at all: whole-Hungarian. if en.Sections[1].Title != "Megosztas" || en.Sections[1].Steps[0] != "Valaszd ki a mappat" { t.Errorf("section 1 should be wholly Hungarian: %+v", en.Sections[1]) } } // TestGuide_OverlayExtrasAreDroppedAndCounted — a translation LONGER than the Hungarian guide is // ignored past the Hungarian count, and the extra is countable so the page can log it once. A // silently dropped translation is the absence nobody notices (CLAUDE.md: an absent line is not // evidence). func TestGuide_OverlayExtrasAreDroppedAndCounted(t *testing.T) { src := strings.Replace(guideYAML, ` - q: "What if I forget the password?" a: "Ask for a new one."`, ` - q: "What if I forget the password?" a: "Ask for a new one." - q: "An extra question nobody wrote in Hungarian" a: "Dropped."`, 1) m := mustGuideMeta(t, src) en := m.For("en").AppInfo.Guide if len(en.FAQ) != 3 { t.Errorf("faq length = %d, want 3 — the extra English entry must be dropped", len(en.FAQ)) } for _, qa := range en.FAQ { if strings.Contains(qa.Q, "nobody wrote") { t.Error("an entry with no Hungarian original reached the page") } } secs, faq := m.GuideOverlayExtras("en") if secs != 0 || faq != 1 { t.Errorf("GuideOverlayExtras(en) = %d, %d; want 0, 1", secs, faq) } // Nothing to warn about for the base language, nor for a correctly sized overlay. if s, f := m.GuideOverlayExtras("hu"); s != 0 || f != 0 { t.Errorf("GuideOverlayExtras(hu) = %d, %d; want 0, 0", s, f) } if s, f := mustGuideMeta(t, guideYAML).GuideOverlayExtras("en"); s != 0 || f != 0 { t.Errorf("a correctly sized overlay warns: %d, %d", s, f) } } // TestGuide_WithAddressFillsEveryString — R-498 over the guide: the household's own address in the // titles, the lead-ins, the steps, the questions and the answers. func TestGuide_WithAddressFillsEveryString(t *testing.T) { ai := mustGuideMeta(t, guideYAML).AppInfo ai.Guide.Sections[1].Title = "Nyisd meg: DOMAIN" ai.Guide.FAQ[0].A = "A wiki.DOMAIN oldalon." got := ai.WithAddress("jegyzet", "pelda.hu") if want := "Nyisd meg a jegyzet.pelda.hu oldalt."; got.Guide.Sections[0].Intro != want { t.Errorf("intro = %q, want %q", got.Guide.Sections[0].Intro, want) } if want := "Add meg a pelda.hu cimet"; got.Guide.Sections[0].Steps[1] != want { t.Errorf("step = %q, want %q", got.Guide.Sections[0].Steps[1], want) } if want := "Nyisd meg: pelda.hu"; got.Guide.Sections[1].Title != want { t.Errorf("title = %q, want %q", got.Guide.Sections[1].Title, want) } if want := "A jegyzet.pelda.hu oldalon."; got.Guide.FAQ[0].A != want { t.Errorf("faq answer = %q, want %q", got.Guide.FAQ[0].A, want) } // The receiver is shared metadata and must be untouched. if ai.Guide.Sections[0].Steps[1] != "Add meg a DOMAIN cimet" { t.Error("WithAddress wrote through into the receiver's guide") } // No domain configured: a placeholder beats a wrong address. if un := ai.WithAddress("jegyzet", ""); !reflect.DeepEqual(un.Guide, ai.Guide) { t.Error("with no domain the guide must come back unchanged") } } // TestGuide_OverlayCloneIsNotShared — deepCopyStack's promise (R-591) over the new overlay: a guide // overlay's pointers and slices are not shared with the copy. func TestGuide_OverlayCloneIsNotShared(t *testing.T) { ov := mustGuideMeta(t, guideYAML).I18n["en"] c := ov.Clone() if c.AppInfo == nil || c.AppInfo.Guide == nil || len(c.AppInfo.Guide.FAQ) != 3 { t.Fatalf("the clone lost the guide overlay: %+v", c.AppInfo) } if c.AppInfo.Guide == ov.AppInfo.Guide { t.Error("the clone shares the guide overlay pointer") } if c.AppInfo.Guide.FAQ[0].Q == ov.AppInfo.Guide.FAQ[0].Q { t.Error("the clone shares a FAQ question pointer") } if *c.AppInfo.Guide.FAQ[0].Q != *ov.AppInfo.Guide.FAQ[0].Q { t.Error("the clone lost the FAQ question's value") } } // TestGuide_CatalogSnapshotCarriesNoGuideYet pins what TestMetaForHuIsIdentity depends on: the real // catalog does not carry a guide yet, so adding the field changed no live app's page. When the // companion task lands the media pilot's guides and the snapshot is refreshed, this test fails — // and the refresher then asserts the guides parse, rather than deleting this. func TestGuide_CatalogSnapshotCarriesNoGuideYet(t *testing.T) { for app, m := range loadCatalogFixtures(t) { if m.AppInfo.Guide.Rendered() || len(m.AppInfo.Guide.Sections) > 0 || len(m.AppInfo.Guide.FAQ) > 0 { t.Errorf("%s: the snapshot now carries a guide — refresh this test's purpose, do not delete it", app) } } }