v0.311.0: after a power cut, an app repairs the files the cut left empty; the household sees lost photos (R-1007)
gates / gates (push) Successful in 52s

New template block after_unclean_boot (decision 236): once per controller start that followed an
UNCLEAN host boot (crashboot's own verdict, after the 15-minute crash grace; a normal or unknown boot
never runs it), each app with the block rebuilds ONLY its zero-byte files (scan: regular files, no
symlinks, its own filesystem; ids appended to the app's own command in batches; one retry, then
reported) and its own lost-file check is read. The household sees lost files (name, date taken) on
the app page until dismissed; the operator gets app_repaired_after_crash with counts only. Skips,
with the reason, an unproven image, an unknown key, an app not running. MinAgent 0.155.0 unchanged.
Tests red-proofed (14 guards). One new parity fixture (the new card only).

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
2026-10-14 16:47:13 +02:00
parent acbbf4b305
commit 8d862e4e69
24 changed files with 1865 additions and 3 deletions
@@ -0,0 +1,47 @@
package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-1007 (decision 236) — the household sees the LOST files only (name and date taken), until it dismisses the note;
// repaired previews never make a note. RED-PROOF: drop `|| rec.Dismissed` in lostFilesNote → the dismissed case
// still shows → fails. RED-PROOF: drop the `.LostFiles` card from app_info.html → the render case fails.
func TestLostFilesNote_OnlyTheLostOnesUntilDismissed(t *testing.T) {
rec := &stacks.AfterUncleanBootRecord{BootAt: "2026-10-14T12:07:26Z", Found: 81, Repaired: 81,
Lost: []stacks.LostFile{{Name: "IMG_0042.jpg", Taken: "2026-10-14 10:41"}}}
lost, at := lostFilesNote(true, &stacks.AppConfig{AfterUncleanBoot: rec})
if len(lost) != 1 || lost[0].Name != "IMG_0042.jpg" || at != "2026-10-14 14:07" {
t.Fatalf("lost=%v at=%q", lost, at)
}
repairedOnly := &stacks.AfterUncleanBootRecord{BootAt: rec.BootAt, Found: 81, Repaired: 81}
if l, _ := lostFilesNote(true, &stacks.AppConfig{AfterUncleanBoot: repairedOnly}); len(l) != 0 {
t.Fatal("repaired previews made a note")
}
dismissed := *rec
dismissed.Dismissed = true
if l, _ := lostFilesNote(true, &stacks.AppConfig{AfterUncleanBoot: &dismissed}); len(l) != 0 {
t.Fatal("a dismissed note is still shown")
}
if l, _ := lostFilesNote(false, &stacks.AppConfig{AfterUncleanBoot: rec}); len(l) != 0 {
t.Fatal("a removed app shows a note")
}
page := renderAppInfoWith(t, map[string]interface{}{"LostFiles": lost, "LostBootAt": at})
for _, want := range []string{`id="lost-files-card"`, "IMG_0042.jpg", "2026-10-14 10:41", "2026-10-14 14:07", "/apps/gapp/lost-files/dismiss", "toltsd fel", "function appPress"} {
if !strings.Contains(asciiFold(page), asciiFold(want)) {
t.Fatalf("the card lacks %q", want)
}
}
if plain := renderAppInfoWith(t, nil); strings.Contains(plain, `id="lost-files-card"`) {
t.Fatal("a card with nothing lost")
}
}
// asciiFold drops the Hungarian accents so the test searches ASCII fragments (the project's rule for Hungarian text).
func asciiFold(s string) string {
return strings.NewReplacer("á", "a", "é", "e", "í", "i", "ó", "o", "ö", "o", "ő", "o", "ú", "u", "ü", "u", "ű", "u").Replace(s)
}