package web import ( "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // THE ONE-COLUMN LAYOUT (v0.316.0, the operator-approved mockup of 2026-10-15). // // These assert STRUCTURE, not strings: the rules the mockup is made of are "one column", "sections // are hairlines, not cards" and "a box means something". Each is checkable in the rendered HTML, // and each had a counter-example in v0.315.0 — which looked, in the operator's words, messy. // TestAppPage_OneColumnLayout — per tab: one column, exactly one current tab, the auto-update // SUCCESS banner gone and its date in the meta line instead, and no section drawn as a card. // // RED-PROOF (REPORT.md): put the success banner back in app_shell.html -> the banner assertion // fails; drop .app-page from a tab -> the column assertion fails. Both reverted. func TestAppPage_OneColumnLayout(t *testing.T) { s := appPageHarness(t) for _, tab := range []string{"", "/settings", "/data", "/guide", "/logs"} { body := appGet(t, s, "/apps/adventurelog"+tab).Body.String() where := "/apps/adventurelog" + tab // ONE COLUMN: the content sits in exactly one .app-page wrapper. if n := strings.Count(body, `class="app-page`); n != 1 { t.Errorf("%s: %d .app-page wrappers, want exactly 1 — the column is what makes the "+ "widths agree", where, n) } // EXACTLY ONE current tab. Two would be a lie about where the household is; none would // leave the bar looking like a row of links, which is what v0.315.0 shipped. if n := strings.Count(body, `aria-current="page"`); n != 1 { t.Errorf("%s: %d aria-current=\"page\", want exactly 1", where, n) } // THE SUCCESS BANNER IS NEVER A BANNER. (Whether this app HAS one is the sub-test below; // here the rule is that no tab ever renders it as an alert.) if strings.Contains(body, `class="alert alert-info" style="margin-top:1rem" data-auto-update="done"`) { t.Errorf("%s: the auto-update SUCCESS banner is back", where) } // NO SECTION IS A CARD. .app-info-card carries a background, a border and a radius; inside // the app page nothing may use it — a section is a hairline. if strings.Contains(body, "app-info-card") { t.Errorf("%s: a section is still drawn as a card (app-info-card)", where) } // The tab bar and the hero are on every tab, and the hero is not a box. for _, marker := range []string{`class="app-back"`, `class="app-hero"`, `class="app-tabs"`} { if !strings.Contains(body, marker) { t.Errorf("%s: missing %s", where, marker) } } if strings.Contains(body, `class="app-info-hero"`) { t.Errorf("%s: the hero is still the boxed one", where) } } } // TestAppPage_AutoUpdateSuccessIsMetaNotBanner — the other half of the rule, on a fixture that // actually has an automatic update to report. v0.315.0 gave it a full-width blue banner above the // tabs, level with "the update failed" and "your files are missing"; a success that asked nothing // of the household is a date. // // RED-PROOF (REPORT.md): restore the `{{else if .AutoUpdateLine}}` banner branch in // app_shell.html -> this fails on the banner assertion. Reverted. func TestAppPage_AutoUpdateSuccessIsMetaNotBanner(t *testing.T) { st := i18nStack("privatebin", "PrivateBin", stacks.StateRunning, true) body := renderBackupPage(t, "app_settings", map[string]interface{}{ "Page": "stacks", "Title": "PrivateBin", "Domain": "example.hu", "CSRFToken": "tok", "Stack": st, "Meta": st.Meta, "AppInfo": st.Meta.AppInfo, "Deployed": true, "AppOperational": true, "AutoUpdateLine": "A frissites 2026. 09. 28-kor sikerult.", // An installed app has a tab bar, and the point of this test is WHERE the line sits // relative to it. "ActiveTab": appTabSettings, "AppTabs": []AppTabLink{ {Key: appTabSettings, Href: "/apps/privatebin/settings", Label: "Beallitasok", Active: true}, {Key: appTabData, Href: "/apps/privatebin/data", Label: "Adatok"}, }, }) if !strings.Contains(body, `class="app-meta" data-auto-update="done"`) { t.Error("the auto-update success line is not the meta line") } if strings.Contains(body, `class="alert alert-info" style="margin-top:1rem" data-auto-update="done"`) { t.Error("the auto-update success is still a banner") } // It sits in the hero, above the tabs — not in the banner strip under it. meta, tabs := strings.Index(body, `data-auto-update="done"`), strings.Index(body, `class="app-tabs"`) if meta < 0 || tabs < 0 || meta > tabs { t.Errorf("the meta line must be in the hero, above the tab bar (meta=%d tabs=%d)", meta, tabs) } } // TestAppPage_CalloutsOnlyForAskAndWarn (Scenario E) — the two boxes, and nothing else. // // RED-PROOF (REPORT.md): draw an informational section as a callout -> the count assertions fail. func TestAppPage_CalloutsOnlyForAskAndWarn(t *testing.T) { // An app WAITING for the household: exactly one blue callout, and it is the after-start card. t.Run("waiting for a value -> one blue callout", func(t *testing.T) { s := afterStartHarness(t, "plex", "PLEX_CLAIM", true) body := appGet(t, s, "/apps/plex").Body.String() if n := strings.Count(body, "app-callout-ask"); n != 1 { t.Errorf("%d blue callouts, want exactly 1 (the after-start card)", n) } if n := strings.Count(body, "app-callout-warn"); n != 0 { t.Errorf("%d amber callouts on an app with nothing wrong, want 0 — amber is for a "+ "deviation, and being asked for a code is not one", n) } // It is the FIRST section: the app is waiting, so nothing else goes above it. ask := strings.Index(body, "app-callout-ask") run := strings.Index(body, `data-run-card="1"`) if ask < 0 || run < 0 || ask > run { t.Errorf("the after-start callout must come before the Mukodes card (ask=%d run=%d)", ask, run) } }) // An app with nothing to ask: no blue callout anywhere. t.Run("nothing to ask -> no blue callout", func(t *testing.T) { s := appPageHarness(t) for _, tab := range []string{"", "/settings", "/data", "/guide", "/logs"} { body := appGet(t, s, "/apps/adventurelog"+tab).Body.String() if n := strings.Count(body, "app-callout-ask"); n != 0 { t.Errorf("/apps/adventurelog%s: %d blue callouts, want 0", tab, n) } } }) // The amber callout is reserved for the risky fact — the app's data sharing a disk with its // backup. Rendered straight, because the harness app is not an HDD app. t.Run("backup on the same disk -> one amber callout", func(t *testing.T) { st := i18nStack("privatebin", "PrivateBin", stacks.StateRunning, true) d := map[string]interface{}{ "Page": "stacks", "Title": "PrivateBin", "Domain": "example.hu", "CSRFToken": "tok", "Stack": st, "Meta": st.Meta, "AppInfo": st.Meta.AppInfo, "Deployed": true, "AppOperational": true, "StackName": "privatebin", "DisplayName": "PrivateBin", "Tier2": map[string]interface{}{"IsHDDApp": false}, "SystemBackup": "same_disk", } body := renderBackupPage(t, "app_data", d) if n := strings.Count(body, "app-callout-warn"); n != 1 { t.Errorf("%d amber callouts, want exactly 1 (the same-disk warning)", n) } if n := strings.Count(body, "app-callout-ask"); n != 0 { t.Errorf("%d blue callouts on the data tab, want 0", n) } if !strings.Contains(body, `data-system-backup="same_disk"`) { t.Error("the same-disk marker is gone") } // The honest branch when the whole-system backup IS on its own drive is NOT a warning. d["SystemBackup"] = "protected" ok := renderBackupPage(t, "app_data", d) if n := strings.Count(ok, "app-callout-warn"); n != 0 { t.Errorf("a protected whole-system backup drew %d amber callouts, want 0 — amber is a "+ "deviation, and this is the good case", n) } }) }