v0.233.0: record what each compose service actually installed, and badge whether it is current
gates / gates (push) Successful in 12s
gates / gates (push) Successful in 12s
Update arc slices 1 and 2. NEITHER CHANGES ANY BEHAVIOUR — no new endpoint, no auto-update, the three lifecycle buttons byte-identical. Slice 1 — app.yaml gains installed_images, keyed by compose SERVICE name, each entry carrying ref + repo digest + first-seen timestamp. Written by Manager.recordInstalledImages after a successful compose up from StartStack, RestartStack, UpdateStack and runComposeDeploy. Read from the CONTAINER, never from docker-compose.yml: the syncer overwrites a deployed app's compose on a 15-minute cycle and the two disagreed for 25 minutes in the spike's own measurement. A failed write NEVER refuses the action - the deliberate opposite of SetDesiredState, because this is an observation and that is an intent. Not called from StartStackServices (the R-47 DB-only window). Its own docker seam with a context and a 30s timeout, which neither existing exec helper has. Slice 2 — .felhom.yml gains optional catalog_since; web.updateBadge compares the recorded ref per service against what the current template pins and returns a *MetaBadge through the EXISTING meta_badge partial. No new markup, no new CSS. NO RECORD RENDERS NOTHING: absent means unknown and never means current. No version number reaches the customer and no registry is queried. Known limitation, filed not hidden: 23 catalog pins float, so those apps can read Naprakesz when the image behind the tag has moved. +17 tests (1707 -> 1724), 28 packages green. Wiring proven through a real RestartStack plus an AST walk of the four call sites. Three companion red-proofs run and reverted.
This commit is contained in:
@@ -473,6 +473,12 @@ func (s *Server) templateFuncMap() template.FuncMap {
|
||||
// there is nothing to say. Pair it with the `meta_badge` partial, which no-ops on nil.
|
||||
// R-56's difficulty badge is meant to be a sibling entry returning the same *MetaBadge.
|
||||
"lifecycleBadge": lifecycleBadge,
|
||||
// updateBadge is the SECOND *MetaBadge-returning entry the type was built for: it says
|
||||
// whether a deployed app is running what the catalog currently pins, and for how long it
|
||||
// has not been. Pair it with the same `meta_badge` partial. Renders NOTHING when there is
|
||||
// no record — absent means unknown, never "up to date". Information only: it is wired to
|
||||
// no action and changes no button.
|
||||
"updateBadge": updateBadge,
|
||||
// canInstall reports whether a catalog template may be OFFERED for a new install. The
|
||||
// server-side deploy gate uses the same stacks.Metadata.CanInstall, so the button and the
|
||||
// endpoint can never disagree.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<span class="stack-state-badge state-{{stateColor .Stack.State}}">{{stateLabel .Stack.State}}</span>
|
||||
{{if .Stack.Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
||||
{{template "meta_badge" (lifecycleBadge .Meta)}}
|
||||
{{template "meta_badge" (updateBadge .Stack)}}
|
||||
{{if .EffectiveSubdomain}}<a href="https://{{.EffectiveSubdomain}}.{{.Domain}}{{.Meta.OpenPath}}" target="_blank" class="btn btn-sm btn-outline">Megnyitás ↗</a>{{end}}
|
||||
<a href="/stacks/{{.Stack.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
|
||||
{{if .Stack.Orphaned}}
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>
|
||||
{{if .Orphaned}}<span class="tag tag-warn">Elavult</span>{{end}}
|
||||
{{template "meta_badge" (lifecycleBadge .Meta)}}
|
||||
{{template "meta_badge" (updateBadge .)}}
|
||||
{{$ms := index $.MissingStorage .Name}}{{if $ms}}<span class="tag tag-warn" title="Az alkalmazás adattárolója nem elérhető. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hiányzó tárhely: {{$ms}}</span>{{end}}
|
||||
{{$ns := index $.NetworkStubs .Name}}{{if $ns}}<span class="tag tag-warn" title="Az alkalmazás környezetében a hálózati tárhely helyén üres helyi könyvtár van — az adatok nem a NAS-ra kerülnek. Jelezze az üzemeltetőnek."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hálózati tárhely hibás — az alkalmazás nem a NAS-t látja</span>{{end}}
|
||||
{{$nw := index $.NetworkWarnings .Name}}{{if $nw}}<span class="tag tag-warn" title="A hálózati tárhely (NAS) jelenleg nem érhető el. Az alkalmazás fut; az adatok elérése a NAS visszatértével helyreáll."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hálózati tárhely nem elérhető: {{$nw}}</span>{{end}}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// updateState is the three-way answer to "is this app running what the catalog currently pins?".
|
||||
//
|
||||
// THREE values, and the third is the entire safety property — the same shape, and the same lesson,
|
||||
// as AppConfig.DesiredState (R-166):
|
||||
//
|
||||
// ABSENT MEANS UNKNOWN. IT NEVER MEANS "UP TO DATE".
|
||||
//
|
||||
// Every app.yaml written before v0.233.0 carries no installed_images, so unknown is the common value
|
||||
// on upgrade. An implementation that fell through to "Naprakész" would tell every customer on the
|
||||
// fleet that their months-old app is current — a confident wrong answer, which is worse than none.
|
||||
type updateState int
|
||||
|
||||
const (
|
||||
updateUnknown updateState = iota // nothing recorded, or nothing to compare against
|
||||
updateCurrent // every service runs exactly what the template pins
|
||||
updateBehind // at least one service does not
|
||||
)
|
||||
|
||||
// compareInstalledToTemplate answers the question WITHOUT touching the network.
|
||||
//
|
||||
// NO REGISTRY QUERY, deliberately: a customer's box must not depend on reaching eight upstream
|
||||
// registries to render a page. The comparison is therefore reference-to-reference — what the
|
||||
// container was created from, against what the compose file now pins.
|
||||
//
|
||||
// KNOWN LIMITATION, stated rather than hidden (see 09-update-architecture.md and the register row):
|
||||
// 23 of the catalog's 66 distinct pins FLOAT (postgres:16-alpine, mariadb:11.6, …). For those the
|
||||
// reference can be identical while the image behind it has moved upstream — measured live in
|
||||
// SPIKE-app-update-2026-09-01 §5, where mariadb:11.4 and mariadb:12.3 had both already moved. Those
|
||||
// apps will read "Naprakész" when they may not be. Closing that needs a registry query and a digest
|
||||
// comparison, which is deferred.
|
||||
func compareInstalledToTemplate(s stacks.Stack) updateState {
|
||||
if !s.Deployed || s.Protected || s.Orphaned {
|
||||
// Not deployed: nothing is running. Protected: infra is ours, not the customer's to update.
|
||||
// Orphaned: the template is gone from the catalog, so there is nothing to be current WITH.
|
||||
return updateUnknown
|
||||
}
|
||||
if s.AppConfig == nil || len(s.AppConfig.InstalledImages) == 0 {
|
||||
return updateUnknown // legacy app.yaml — no record was ever written
|
||||
}
|
||||
if len(s.TemplateImages) == 0 {
|
||||
return updateUnknown // the compose file could not be read or pins nothing
|
||||
}
|
||||
if len(s.AppConfig.InstalledImages) != len(s.TemplateImages) {
|
||||
// A service was added or removed by the template. That IS a change the customer's running
|
||||
// stack has not taken up.
|
||||
return updateBehind
|
||||
}
|
||||
for svc, want := range s.TemplateImages {
|
||||
got, ok := s.AppConfig.InstalledImages[svc]
|
||||
if !ok || got.Ref != want {
|
||||
return updateBehind
|
||||
}
|
||||
}
|
||||
return updateCurrent
|
||||
}
|
||||
|
||||
// updateBadgeAt is the pure form: `now` is injected so the age is a testable contract rather than a
|
||||
// property of the clock. updateBadge (the funcmap entry) is the one-line wrapper.
|
||||
//
|
||||
// It returns a *MetaBadge and calls the EXISTING meta_badge partial — no new markup and no new CSS.
|
||||
// metabadge.go's own comment asks for exactly that of its second user, and this is it.
|
||||
func updateBadgeAt(s stacks.Stack, now time.Time) *MetaBadge {
|
||||
switch compareInstalledToTemplate(s) {
|
||||
case updateCurrent:
|
||||
return &MetaBadge{
|
||||
Label: "Naprakész",
|
||||
Class: "tag-ok",
|
||||
Title: "Ez az alkalmazás a legfrissebb elérhető változatot futtatja.",
|
||||
}
|
||||
case updateBehind:
|
||||
label := "Frissítés elérhető"
|
||||
if days, ok := s.Meta.CatalogSinceAge(now); ok {
|
||||
if days == 0 {
|
||||
label += " — ma"
|
||||
} else {
|
||||
label += fmt.Sprintf(" — %d napja", days)
|
||||
}
|
||||
}
|
||||
return &MetaBadge{
|
||||
Label: label,
|
||||
Class: "tag-warn",
|
||||
Title: "Újabb változat érhető el ehhez az alkalmazáshoz. " +
|
||||
"A frissítés indításához nyomd meg a Frissítés gombot.",
|
||||
}
|
||||
default:
|
||||
// UNKNOWN renders NOTHING. Not a grey "ismeretlen" pill: a badge on an app we cannot judge
|
||||
// is a question the customer cannot answer, and the record fills itself in on the next
|
||||
// restart or update anyway.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// updateBadge is the funcmap entry. NO version number appears in any string it produces — the
|
||||
// operator ruled that a household cannot act on "26.05.2", only on "you are behind, by this long".
|
||||
// Version strings stay in the logs, the API and the hub.
|
||||
//
|
||||
// It is INFORMATION ONLY. It is wired to no action, and the Frissítés button is untouched.
|
||||
func updateBadge(s stacks.Stack) *MetaBadge { return updateBadgeAt(s, time.Now().UTC()) }
|
||||
@@ -0,0 +1,268 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// Slice 2 (v0.233.0) — the badge. FOUR states, and the fourth is the load-bearing one:
|
||||
// NO RECORD RENDERS NOTHING. An app with no record showing "Naprakész" is the R-166 failure in a
|
||||
// new place — absent means UNKNOWN and never means current.
|
||||
|
||||
var badgeNow = time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// ubStack builds a deployed app whose record and template pins are stated explicitly.
|
||||
func ubStack(installed map[string]stacks.InstalledImage, template map[string]string, since string) stacks.Stack {
|
||||
return stacks.Stack{
|
||||
Name: "bookstack",
|
||||
Deployed: true,
|
||||
State: stacks.StateRunning,
|
||||
Meta: stacks.Metadata{DisplayName: "BookStack", Slug: "bookstack", CatalogSince: since},
|
||||
AppConfig: &stacks.AppConfig{Deployed: true, InstalledImages: installed},
|
||||
TemplateImages: template,
|
||||
}
|
||||
}
|
||||
|
||||
func rec(ref string) stacks.InstalledImage {
|
||||
return stacks.InstalledImage{Ref: ref, Digest: "sha256:x", At: "2026-09-01T00:00:00Z"}
|
||||
}
|
||||
|
||||
// --- GROUP D: the four states ---
|
||||
|
||||
// TestGroupD_FourStates.
|
||||
//
|
||||
// COMPANION RED-PROOF (run 2026-09-02): in compareInstalledToTemplate, change the
|
||||
// `len(s.AppConfig.InstalledImages) == 0` guard to fall through to updateCurrent instead of
|
||||
// updateUnknown — i.e. the trivial implementation that treats "we never wrote it down" as
|
||||
// "up to date". The `no record at all` sub-test then fails with a "Naprakész" badge on an app
|
||||
// nobody has ever measured. Reverted.
|
||||
func TestGroupD_FourStates(t *testing.T) {
|
||||
tpl := map[string]string{"web": "lscr.io/linuxserver/bookstack:26.05.2", "db": "mariadb:12.3"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
stack stacks.Stack
|
||||
wantBadge bool
|
||||
wantLabel string
|
||||
wantClass string
|
||||
}{
|
||||
{
|
||||
name: "current",
|
||||
stack: ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"]), "db": rec(tpl["db"])}, tpl, "2026-07-18"),
|
||||
wantBadge: true, wantLabel: "Naprakész", wantClass: "tag-ok",
|
||||
},
|
||||
{
|
||||
name: "behind, age known",
|
||||
stack: ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2"), "db": rec(tpl["db"])}, tpl, "2026-07-18"),
|
||||
wantBadge: true, wantLabel: "Frissítés elérhető — 46 napja", wantClass: "tag-warn",
|
||||
},
|
||||
{
|
||||
name: "behind, age unknown",
|
||||
stack: ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2"), "db": rec(tpl["db"])}, tpl, ""),
|
||||
wantBadge: true, wantLabel: "Frissítés elérhető", wantClass: "tag-warn",
|
||||
},
|
||||
{
|
||||
name: "behind, catalog moved TODAY",
|
||||
stack: ubStack(map[string]stacks.InstalledImage{"web": rec("old:1"), "db": rec(tpl["db"])}, tpl, "2026-09-02"),
|
||||
wantBadge: true, wantLabel: "Frissítés elérhető — ma", wantClass: "tag-warn",
|
||||
},
|
||||
{
|
||||
name: "NO RECORD AT ALL (legacy app.yaml) — nothing rendered",
|
||||
stack: ubStack(nil, tpl, "2026-07-18"),
|
||||
wantBadge: false,
|
||||
},
|
||||
{
|
||||
name: "a service was ADDED by the template",
|
||||
stack: ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"])}, tpl, "2026-07-18"),
|
||||
wantBadge: true, wantLabel: "Frissítés elérhető — 46 napja", wantClass: "tag-warn",
|
||||
},
|
||||
{
|
||||
name: "template unreadable — nothing rendered",
|
||||
stack: ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"]), "db": rec(tpl["db"])}, nil, "2026-07-18"),
|
||||
wantBadge: false,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
b := updateBadgeAt(c.stack, badgeNow)
|
||||
if (b != nil) != c.wantBadge {
|
||||
t.Fatalf("badge = %+v, want present=%v", b, c.wantBadge)
|
||||
}
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
if b.Label != c.wantLabel {
|
||||
t.Errorf("label = %q, want %q", b.Label, c.wantLabel)
|
||||
}
|
||||
if b.Class != c.wantClass {
|
||||
t.Errorf("class = %q, want %q", b.Class, c.wantClass)
|
||||
}
|
||||
if b.Title == "" {
|
||||
t.Error("a badge that is only a word is a riddle — it must carry an explanation")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupD_NoVersionNumberIsEverShown — the operator ruled it: a household cannot act on
|
||||
// "26.05.2". Version strings stay in the logs, the API and the hub.
|
||||
func TestGroupD_NoVersionNumberIsEverShown(t *testing.T) {
|
||||
tpl := map[string]string{"web": "lscr.io/linuxserver/bookstack:26.05.2"}
|
||||
for _, s := range []stacks.Stack{
|
||||
ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"])}, tpl, "2026-07-18"),
|
||||
ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2")}, tpl, "2026-07-18"),
|
||||
} {
|
||||
b := updateBadgeAt(s, badgeNow)
|
||||
if b == nil {
|
||||
t.Fatal("expected a badge")
|
||||
}
|
||||
for _, forbidden := range []string{"26.05.2", "25.02.2", "bookstack:", "mariadb", "sha256"} {
|
||||
if strings.Contains(b.Label+b.Title, forbidden) {
|
||||
t.Errorf("badge text leaks %q: label=%q title=%q", forbidden, b.Label, b.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupD_NothingIsBadgedThatCannotBeJudged — undeployed, protected and orphaned apps.
|
||||
func TestGroupD_NothingIsBadgedThatCannotBeJudged(t *testing.T) {
|
||||
tpl := map[string]string{"web": "nginx:1.27"}
|
||||
inst := map[string]stacks.InstalledImage{"web": rec("nginx:1.26")}
|
||||
for name, mutate := range map[string]func(*stacks.Stack){
|
||||
"not deployed": func(s *stacks.Stack) { s.Deployed = false },
|
||||
"protected": func(s *stacks.Stack) { s.Protected = true },
|
||||
"orphaned": func(s *stacks.Stack) { s.Orphaned = true },
|
||||
} {
|
||||
s := ubStack(inst, tpl, "2026-07-18")
|
||||
mutate(&s)
|
||||
if b := updateBadgeAt(s, badgeNow); b != nil {
|
||||
t.Errorf("%s: rendered %q — there is nothing to be current WITH", name, b.Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- GROUP D, rendered: the PRODUCTION templates ---
|
||||
|
||||
func ubAppInfoData(st stacks.Stack) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"Page": "stacks", "Title": st.Meta.DisplayName,
|
||||
"Stack": &st, "Meta": st.Meta, "AppInfo": st.Meta.AppInfo,
|
||||
"HasAppInfo": st.Meta.HasAppInfo(), "EffectiveSubdomain": st.Meta.Subdomain,
|
||||
"Domain": "demo-felhom.eu",
|
||||
}
|
||||
}
|
||||
|
||||
func ubStacksData(st stacks.Stack) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"Page": "stacks", "Title": "Alkalmazások",
|
||||
"Stacks": []stacks.Stack{st},
|
||||
"MissingStorage": map[string]string{},
|
||||
"NetworkWarnings": map[string]string{},
|
||||
"NetworkStubs": map[string]string{},
|
||||
"StorageLabels": map[string]string{},
|
||||
"Subdomains": map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupD_BadgeRendersOnBothSurfaces renders the REAL templates, which is the only thing that
|
||||
// catches a template-time failure: `.Stack` reaches app_info as a *stacks.Stack, and a funcmap entry
|
||||
// taking a VALUE has to be reachable from it. A compile-clean funcmap that 500s at render is exactly
|
||||
// how v0.158.0's pointer-receiver defect shipped.
|
||||
//
|
||||
// COMPANION RED-PROOF (run 2026-09-02): delete the {{template "meta_badge" (updateBadge …)}} line
|
||||
// from stacks.html and the "app list" sub-test fails; delete it from app_info.html and the "app page"
|
||||
// sub-test fails. Reverted.
|
||||
func TestGroupD_BadgeRendersOnBothSurfaces(t *testing.T) {
|
||||
tpl := map[string]string{"web": "lscr.io/linuxserver/bookstack:26.05.2"}
|
||||
behind := ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2")}, tpl, "2026-07-18")
|
||||
current := ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"])}, tpl, "2026-07-18")
|
||||
legacy := ubStack(nil, tpl, "2026-07-18")
|
||||
|
||||
for _, surface := range []struct {
|
||||
name string
|
||||
data func(stacks.Stack) map[string]interface{}
|
||||
tmpl string
|
||||
}{
|
||||
{"app page", ubAppInfoData, "app_info"},
|
||||
{"app list", ubStacksData, "stacks"},
|
||||
} {
|
||||
t.Run(surface.name, func(t *testing.T) {
|
||||
h := renderBackupPage(t, surface.tmpl, surface.data(behind))
|
||||
if !strings.Contains(h, "Frissítés elérhető — 46 napja") {
|
||||
t.Errorf("the behind badge is missing from %s", surface.tmpl)
|
||||
}
|
||||
h = renderBackupPage(t, surface.tmpl, surface.data(current))
|
||||
if !strings.Contains(h, "Naprakész") {
|
||||
t.Errorf("the current badge is missing from %s", surface.tmpl)
|
||||
}
|
||||
// The negative half. Without it, an implementation that badges everything passes.
|
||||
h = renderBackupPage(t, surface.tmpl, surface.data(legacy))
|
||||
if strings.Contains(h, "Naprakész") || strings.Contains(h, "Frissítés elérhető") {
|
||||
t.Errorf("an app with NO RECORD must be badged with NOTHING on %s", surface.tmpl)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- SCENARIO E: nothing about updating changed ---
|
||||
|
||||
// TestScenarioE_TheUpdateButtonIsUntouched. This slice is information only. The badge must be wired
|
||||
// to no action, and the three lifecycle buttons must render exactly as they did before it existed.
|
||||
func TestScenarioE_TheUpdateButtonIsUntouched(t *testing.T) {
|
||||
tpl := map[string]string{"web": "nginx:1.27"}
|
||||
states := map[string]stacks.Stack{
|
||||
"current": ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.27")}, tpl, "2026-07-18"),
|
||||
"behind": ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.26")}, tpl, "2026-07-18"),
|
||||
"behind/na": ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.26")}, tpl, ""),
|
||||
"no record": ubStack(nil, tpl, "2026-07-18"),
|
||||
}
|
||||
for name, st := range states {
|
||||
h := renderBackupPage(t, "stacks", ubStacksData(st))
|
||||
for _, want := range []string{
|
||||
`stackAction(event, 'bookstack', 'update')`,
|
||||
`stackAction(event, 'bookstack', 'restart')`,
|
||||
`stackAction(event, 'bookstack', 'stop')`,
|
||||
} {
|
||||
if !strings.Contains(h, want) {
|
||||
t.Errorf("%s: the %q button changed — this slice must change no behaviour", name, want)
|
||||
}
|
||||
}
|
||||
// No new action may hang off the badge.
|
||||
if strings.Contains(h, "updateBadgeAction") || strings.Contains(h, "'autoupdate'") {
|
||||
t.Errorf("%s: the badge must be wired to NOTHING", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- GROUP F: catalog_since tolerance ---
|
||||
|
||||
// TestGroupF_CatalogSinceTolerance — absent, empty, malformed and FUTURE all degrade to "no age
|
||||
// known" and never brick the badge. The future case is not pedantry: a box whose clock is behind the
|
||||
// catalog would otherwise print "-3 napja".
|
||||
func TestGroupF_CatalogSinceTolerance(t *testing.T) {
|
||||
for _, since := range []string{"", " ", "tegnap", "18/07/2026", "2026-13-45", "2026-12-31"} {
|
||||
m := stacks.Metadata{CatalogSince: since}
|
||||
if days, ok := m.CatalogSinceAge(badgeNow); ok {
|
||||
t.Errorf("catalog_since %q must be UNUSABLE, got %d napja", since, days)
|
||||
}
|
||||
tpl := map[string]string{"web": "nginx:1.27"}
|
||||
s := ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.26")}, tpl, since)
|
||||
b := updateBadgeAt(s, badgeNow)
|
||||
if b == nil {
|
||||
t.Fatalf("catalog_since %q: the badge must still render, just without an age", since)
|
||||
}
|
||||
if b.Label != "Frissítés elérhető" {
|
||||
t.Errorf("catalog_since %q: label = %q, want the age-less form", since, b.Label)
|
||||
}
|
||||
}
|
||||
// And the usable cases.
|
||||
for since, want := range map[string]int{"2026-09-02": 0, "2026-09-01": 1, "2026-07-18": 46} {
|
||||
got, ok := stacks.Metadata{CatalogSince: since}.CatalogSinceAge(badgeNow)
|
||||
if !ok || got != want {
|
||||
t.Errorf("catalog_since %q → (%d, %v), want (%d, true)", since, got, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user