R-563: the remote-backup page polls from a status attribute, and „Fut…" is finally translatable

- backups_remote.html: the status stat carries data-status="{{.Offbox.LastStatus}}"; the poll reads
  v.dataset.status === 'running' instead of v.textContent.indexOf('Fut'). The English page could never
  see a running backup before this (slice 1 shipped the English page with that word left Hungarian).
- The running label becomes {{T "backups_remote.fut"}} — hu „Fut…" unchanged, en „Running…".
- 12 backups_remote parity fixtures re-captured. Proven (audit switch/fixture diff): each differs from
  its predecessor ONLY by the data-status attribute and that one poll line; the other 94 are
  byte-identical. The displayed Hungarian is unchanged.
- Tests: TestR563_PollStartsFromAttribute (hu and en), TestR563_AttributeFollowsTheStatus.
  Red-proofed twice: poll back on textContent → both languages fail; word back inline → the English
  page shows Hungarian.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-17 21:02:47 +02:00
parent c00fed6db3
commit f806baf4c7
16 changed files with 144 additions and 23 deletions
@@ -0,0 +1,80 @@
package web
import (
"regexp"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-563 — the Távoli mentés page starts its progress poll from a MACHINE-READABLE status, not from
// the word it happens to display.
//
// Until v0.251.0 the page did `v.textContent.indexOf('Fut') >= 0`. Two consequences, both measured:
// the English page (slice 1 shipped it) could never see a running backup, because the English word is
// „Running…"; and the Hungarian word could not be translated at all while that line existed — it was
// the ONE word slice 1 had to leave behind.
//
// RED-PROOF (REPORT): put the textContent test back in backups_remote.html and the English half fails.
func TestR563_PollStartsFromAttribute(t *testing.T) {
statusEl := regexp.MustCompile(`<div class="stat-value" id="offbox-status-value"([^>]*)>([^<]*)</div>`)
for _, lang := range []string{"hu", "en"} {
data := appRowSplitData()
data["Offbox"] = &settings.OffboxTarget{
Enabled: true, Host: "nas.local", LastStatus: "running", EscrowState: "escrowed",
}
html := renderBackupPageLang(t, lang, "backups_remote", data)
m := statusEl.FindStringSubmatch(html)
if m == nil {
t.Fatalf("[%s] the status element is gone — this test no longer reads what it thinks it reads", lang)
}
if !strings.Contains(m[1], `data-status="running"`) {
t.Errorf("[%s] the status element carries no machine-readable status: %q", lang, m[1])
}
if !strings.Contains(html, "v.dataset.status === 'running'") {
t.Errorf("[%s] the poll does not read the attribute", lang)
}
if strings.Contains(html, "textContent.indexOf('Fut')") {
t.Errorf("[%s] the poll reads the displayed Hungarian word again (R-563)", lang)
}
// The displayed word itself: Hungarian as always, English on the English page. Before this
// change the English page showed the Hungarian word here, because it could not be translated.
word := strings.TrimSpace(m[2])
want := map[string]string{"hu": "Fut…", "en": "Running…"}[lang]
if word != want {
t.Errorf("[%s] status label = %q, want %q", lang, word, want)
}
}
}
// A state that is NOT running must not claim to be: the attribute follows LastStatus exactly, so the
// poll cannot be started by a word that merely contains another.
func TestR563_AttributeFollowsTheStatus(t *testing.T) {
statusEl := regexp.MustCompile(`id="offbox-status-value" data-status="([a-z]*)"`)
for _, st := range []string{"ok", "error", "incomplete", "running"} {
data := appRowSplitData()
data["Offbox"] = &settings.OffboxTarget{Enabled: true, Host: "nas.local", LastStatus: st, EscrowState: "escrowed"}
html := renderBackupPage(t, "backups_remote", data)
m := statusEl.FindStringSubmatch(html)
if m == nil {
t.Fatalf("[%s] no data-status attribute on the status element", st)
}
if m[1] != st {
t.Errorf("[%s] data-status = %q — the poll would read the wrong state", st, m[1])
}
}
}
// renderBackupPageLang renders a backup page from a chosen language's template set (R-563 needs the
// English page: it is the one that could never see a running backup).
func renderBackupPageLang(t *testing.T, lang, page string, data map[string]interface{}) string {
t.Helper()
s := testServer(t)
s.loadTemplates()
var buf strings.Builder
if err := s.templatesFor(lang).ExecuteTemplate(&buf, page, data); err != nil {
t.Fatalf("render %s [%s]: %v", page, lang, err)
}
return buf.String()
}
@@ -43,7 +43,7 @@
{{if .Offbox}}
<div class="stats-grid backup-page-cards">
<div class="stat-card {{if or (eq .Offbox.LastStatus "error") (eq .Offbox.LastStatus "incomplete")}}stat-warn{{end}}">
<div class="stat-value" id="offbox-status-value" style="font-size:1.15rem">{{if eq .Offbox.LastStatus "ok"}}{{T "backups_remote.rendben"}}{{else if eq .Offbox.LastStatus "incomplete"}}{{T "backups_remote.hianyos"}}{{else if eq .Offbox.LastStatus "error"}}{{T "backups_remote.hiba"}}{{else if eq .Offbox.LastStatus "running"}}Fut…{{else}}–{{end}}</div>
<div class="stat-value" id="offbox-status-value" data-status="{{.Offbox.LastStatus}}" style="font-size:1.15rem">{{if eq .Offbox.LastStatus "ok"}}{{T "backups_remote.rendben"}}{{else if eq .Offbox.LastStatus "incomplete"}}{{T "backups_remote.hianyos"}}{{else if eq .Offbox.LastStatus "error"}}{{T "backups_remote.hiba"}}{{else if eq .Offbox.LastStatus "running"}}{{T "backups_remote.fut"}}{{else}}–{{end}}</div>
<div class="stat-label">{{T "backups_remote.utolso_tavoli_mentes"}}{{if .Offbox.LastRun}}<br><span class="relative-time">{{timeAgoStr .Offbox.LastRun}}</span>{{end}}</div>
</div>
<div class="stat-card">
@@ -277,7 +277,10 @@
var bar = document.getElementById('offbox-progress-bar');
var GRACE = 5; // ~15s of quiet before concluding nothing is running
var quiet = 0;
var sawRunning = (v && v.textContent.indexOf('Fut') >= 0);
// R-563: the page asks the ELEMENT what the status is (data-status, rendered server-side from
// Offbox.LastStatus), never the word it displays. Reading „Fut" meant an English page never noticed
// a running backup — and the word could not be translated while this line existed.
var sawRunning = (v && v.dataset.status === 'running');
var timer = setInterval(function(){
fetch('/backup/offbox/status',{headers:{'Accept':'application/json'}})
@@ -194,7 +194,10 @@
var bar = document.getElementById('offbox-progress-bar');
var GRACE = 5;
var quiet = 0;
var sawRunning = (v && v.textContent.indexOf('Fut') >= 0);
var sawRunning = (v && v.dataset.status === 'running');
var timer = setInterval(function(){
fetch('/backup/offbox/status',{headers:{'Accept':'application/json'}})
@@ -185,7 +185,7 @@
<div class="stats-grid backup-page-cards">
<div class="stat-card stat-warn">
<div class="stat-value" id="offbox-status-value" style="font-size:1.15rem">&#10007; Hiba</div>
<div class="stat-value" id="offbox-status-value" data-status="error" style="font-size:1.15rem">&#10007; Hiba</div>
<div class="stat-label">Utolsó távoli mentés<br><span class="relative-time"># napja</span></div>
</div>
<div class="stat-card">
@@ -269,7 +269,10 @@
var bar = document.getElementById('offbox-progress-bar');
var GRACE = 5;
var quiet = 0;
var sawRunning = (v && v.textContent.indexOf('Fut') >= 0);
var sawRunning = (v && v.dataset.status === 'running');
var timer = setInterval(function(){
fetch('/backup/offbox/status',{headers:{'Accept':'application/json'}})
@@ -185,7 +185,7 @@
<div class="stats-grid backup-page-cards">
<div class="stat-card ">
<div class="stat-value" id="offbox-status-value" style="font-size:1.15rem">&#10003; Rendben</div>
<div class="stat-value" id="offbox-status-value" data-status="ok" style="font-size:1.15rem">&#10003; Rendben</div>
<div class="stat-label">Utolsó távoli mentés<br><span class="relative-time"># napja</span></div>
</div>
<div class="stat-card">
@@ -272,7 +272,10 @@
var bar = document.getElementById('offbox-progress-bar');
var GRACE = 5;
var quiet = 0;
var sawRunning = (v && v.textContent.indexOf('Fut') >= 0);
var sawRunning = (v && v.dataset.status === 'running');
var timer = setInterval(function(){
fetch('/backup/offbox/status',{headers:{'Accept':'application/json'}})
@@ -197,7 +197,7 @@
<div class="stats-grid backup-page-cards">
<div class="stat-card ">
<div class="stat-value" id="offbox-status-value" style="font-size:1.15rem">&#10003; Rendben</div>
<div class="stat-value" id="offbox-status-value" data-status="ok" style="font-size:1.15rem">&#10003; Rendben</div>
<div class="stat-label">Utolsó távoli mentés<br><span class="relative-time"># napja</span></div>
</div>
<div class="stat-card">
@@ -370,7 +370,10 @@
var bar = document.getElementById('offbox-progress-bar');
var GRACE = 5;
var quiet = 0;
var sawRunning = (v && v.textContent.indexOf('Fut') >= 0);
var sawRunning = (v && v.dataset.status === 'running');
var timer = setInterval(function(){
fetch('/backup/offbox/status',{headers:{'Accept':'application/json'}})
@@ -185,7 +185,7 @@
<div class="stats-grid backup-page-cards">
<div class="stat-card stat-warn">
<div class="stat-value" id="offbox-status-value" style="font-size:1.15rem">&#33; Hiányos</div>
<div class="stat-value" id="offbox-status-value" data-status="incomplete" style="font-size:1.15rem">&#33; Hiányos</div>
<div class="stat-label">Utolsó távoli mentés<br><span class="relative-time"># napja</span></div>
</div>
<div class="stat-card">
@@ -284,7 +284,10 @@
var bar = document.getElementById('offbox-progress-bar');
var GRACE = 5;
var quiet = 0;
var sawRunning = (v && v.textContent.indexOf('Fut') >= 0);
var sawRunning = (v && v.dataset.status === 'running');
var timer = setInterval(function(){
fetch('/backup/offbox/status',{headers:{'Accept':'application/json'}})
@@ -220,7 +220,10 @@
var bar = document.getElementById('offbox-progress-bar');
var GRACE = 5;
var quiet = 0;
var sawRunning = (v && v.textContent.indexOf('Fut') >= 0);
var sawRunning = (v && v.dataset.status === 'running');
var timer = setInterval(function(){
fetch('/backup/offbox/status',{headers:{'Accept':'application/json'}})
@@ -221,7 +221,10 @@
var bar = document.getElementById('offbox-progress-bar');
var GRACE = 5;
var quiet = 0;
var sawRunning = (v && v.textContent.indexOf('Fut') >= 0);
var sawRunning = (v && v.dataset.status === 'running');
var timer = setInterval(function(){
fetch('/backup/offbox/status',{headers:{'Accept':'application/json'}})
@@ -185,7 +185,7 @@
<div class="stats-grid backup-page-cards">
<div class="stat-card ">
<div class="stat-value" id="offbox-status-value" style="font-size:1.15rem">–</div>
<div class="stat-value" id="offbox-status-value" data-status="never" style="font-size:1.15rem">–</div>
<div class="stat-label">Utolsó távoli mentés<br><span class="relative-time"># napja</span></div>
</div>
<div class="stat-card">
@@ -275,7 +275,10 @@
var bar = document.getElementById('offbox-progress-bar');
var GRACE = 5;
var quiet = 0;
var sawRunning = (v && v.textContent.indexOf('Fut') >= 0);
var sawRunning = (v && v.dataset.status === 'running');
var timer = setInterval(function(){
fetch('/backup/offbox/status',{headers:{'Accept':'application/json'}})
@@ -185,7 +185,7 @@
<div class="stats-grid backup-page-cards">
<div class="stat-card ">
<div class="stat-value" id="offbox-status-value" style="font-size:1.15rem">&#10003; Rendben</div>
<div class="stat-value" id="offbox-status-value" data-status="ok" style="font-size:1.15rem">&#10003; Rendben</div>
<div class="stat-label">Utolsó távoli mentés<br><span class="relative-time"># napja</span></div>
</div>
<div class="stat-card">
@@ -275,7 +275,10 @@
var bar = document.getElementById('offbox-progress-bar');
var GRACE = 5;
var quiet = 0;
var sawRunning = (v && v.textContent.indexOf('Fut') >= 0);
var sawRunning = (v && v.dataset.status === 'running');
var timer = setInterval(function(){
fetch('/backup/offbox/status',{headers:{'Accept':'application/json'}})
@@ -195,7 +195,7 @@
<div class="stats-grid backup-page-cards">
<div class="stat-card ">
<div class="stat-value" id="offbox-status-value" style="font-size:1.15rem">Fut…</div>
<div class="stat-value" id="offbox-status-value" data-status="running" style="font-size:1.15rem">Fut…</div>
<div class="stat-label">Utolsó távoli mentés<br><span class="relative-time"># napja</span></div>
</div>
<div class="stat-card">
@@ -289,7 +289,10 @@
var bar = document.getElementById('offbox-progress-bar');
var GRACE = 5;
var quiet = 0;
var sawRunning = (v && v.textContent.indexOf('Fut') >= 0);
var sawRunning = (v && v.dataset.status === 'running');
var timer = setInterval(function(){
fetch('/backup/offbox/status',{headers:{'Accept':'application/json'}})
@@ -185,7 +185,7 @@
<div class="stats-grid backup-page-cards">
<div class="stat-card ">
<div class="stat-value" id="offbox-status-value" style="font-size:1.15rem">&#10003; Rendben</div>
<div class="stat-value" id="offbox-status-value" data-status="ok" style="font-size:1.15rem">&#10003; Rendben</div>
<div class="stat-label">Utolsó távoli mentés<br><span class="relative-time"># napja</span></div>
</div>
<div class="stat-card">
@@ -273,7 +273,10 @@
var bar = document.getElementById('offbox-progress-bar');
var GRACE = 5;
var quiet = 0;
var sawRunning = (v && v.textContent.indexOf('Fut') >= 0);
var sawRunning = (v && v.dataset.status === 'running');
var timer = setInterval(function(){
fetch('/backup/offbox/status',{headers:{'Accept':'application/json'}})
@@ -185,7 +185,7 @@
<div class="stats-grid backup-page-cards">
<div class="stat-card ">
<div class="stat-value" id="offbox-status-value" style="font-size:1.15rem">&#10003; Rendben</div>
<div class="stat-value" id="offbox-status-value" data-status="ok" style="font-size:1.15rem">&#10003; Rendben</div>
<div class="stat-label">Utolsó távoli mentés<br><span class="relative-time"># napja</span></div>
</div>
<div class="stat-card">
@@ -273,7 +273,10 @@
var bar = document.getElementById('offbox-progress-bar');
var GRACE = 5;
var quiet = 0;
var sawRunning = (v && v.textContent.indexOf('Fut') >= 0);
var sawRunning = (v && v.dataset.status === 'running');
var timer = setInterval(function(){
fetch('/backup/offbox/status',{headers:{'Accept':'application/json'}})