Files
gosentry/src/ui/history_view_test.go
T
mix 18da021526 chore: land the remaining low-severity items from the whole-project review
Phase 11 of PROJECT_REVIEW_PLAN.md: the themed cleanup pass over every
low-severity finding still open (2.2-2.3, 3.4-3.6, 4.3-4.7, 6.4-6.7,
7.1-7.3, 8.2-8.3, 9.1-9.4, and the under-documented decisions in §10/§11).

Behavioral fixes:
- Reassign duplicate job IDs in a hand-edited jobs.json instead of letting
  two jobs share one runtime, schedule entry, and SeedStats bucket.
- Disambiguate run-log file names that collide within the same second.
- Compute AvgDurationMS as DurationSumMS/TimedRunCount instead of an
  incremental integer mean, so it always matches the seeded-from-logs
  average instead of drifting from truncation error.
- Clean absolute paths in ResolveConfiguredPath so two spellings of the
  same jobs file do not trigger a spurious adoption.
- Report InstallDesktopIcon failures through ErrorOccurred instead of
  discarding them silently.
- Move settingsView's blocking AutostartStatus (PowerShell on Windows) off
  the UI thread.
- Give notify-timing.tsv its own extension so CleanupLogs no longer
  manages it as a run log.
- Replace the settingsView Save handler's second copy of validateConfig's
  rules with a bare parse, letting the Service's own error surface.

Cleanups:
- Delete collectActivity, the dead yaml tags on RunRecord, and the
  logArguments/LogArguments alias.
- Fold the two systemTrayRegistered/mainWindowHidden globals into one
  trayState instance Run owns and threads through Settings and the
  single-instance reveal path.
- Fix stale comments/docs: the frozen window-size restore claim, a
  reference to a renamed recordRun, README's "Pause all" and notification
  wording, the PowerShell quoting note for TESTS.md's coverage command,
  and scripts/test.bat's UTF-8 checkmarks under a non-UTF-8 code page.
- Document the single-instance fallback's consequence and the
  unauthenticated instance-channel port in STANDARDS.md; record the
  config-shim retirement plan in ROADMAP.md.

3.5, 7.3, and 9.4 turned out to already be fixed by earlier phases; no
change needed for those three.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 03:35:10 +03:00

368 lines
12 KiB
Go

package ui
import (
"strconv"
"strings"
"testing"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/test"
"fyne.io/fyne/v2/widget"
)
func TestLastJobLogsCapsAndCopies(t *testing.T) {
logs := []event{
{Time: "1", JobName: "a"},
{Time: "2", JobName: "b"},
{Time: "3", JobName: "c"},
{Time: "4", JobName: "d"},
}
got := lastJobLogs(logs)
if len(got) != maxJobActivityRows {
t.Fatalf("len = %d, want %d", len(got), maxJobActivityRows)
}
for i, want := range []string{"1", "2", "3"} {
if got[i].Time != want {
t.Errorf("got[%d].Time = %q, want %q", i, got[i].Time, want)
}
}
logs[0].Time = "mutated"
if got[0].Time == "mutated" {
t.Error("lastJobLogs must return a defensive copy")
}
}
func TestLastJobLogsEmpty(t *testing.T) {
if got := lastJobLogs(nil); len(got) != 0 {
t.Errorf("nil input: got %v, want empty", got)
}
}
func TestIndexOfID(t *testing.T) {
jobs := []job{
{ID: 10, Name: "A"},
{ID: 20, Name: "B"},
}
if got := indexOfID(jobs, 20); got != 1 {
t.Errorf("found: got %d, want 1", got)
}
if got := indexOfID(jobs, 99); got != -1 {
t.Errorf("missing: got %d, want -1", got)
}
if got := indexOfID(nil, 1); got != -1 {
t.Errorf("empty slice: got %d, want -1", got)
}
}
func TestHistoryCellText(t *testing.T) {
events := []event{{
Time: "2026-06-01 12:00:00",
Trigger: "",
JobName: "Job",
State: "OK",
Detail: "done",
LogFile: `/logs/20260601-120000_Job.log`,
}}
cases := []struct {
col int
want string
}{
{0, "2026-06-01 12:00:00"},
{1, "Unknown"},
{2, "Job"},
{3, "OK"},
{4, "done"},
{5, "20260601-120000_Job.log"},
}
for _, tc := range cases {
got := historyCellText(widget.TableCellID{Row: 0, Col: tc.col}, events)
if got != tc.want {
t.Errorf("col %d: got %q, want %q", tc.col, got, tc.want)
}
}
if got := historyCellText(widget.TableCellID{Row: -1, Col: 0}, events); got != "" {
t.Errorf("header row: got %q, want empty", got)
}
if got := historyCellText(widget.TableCellID{Row: 99, Col: 0}, events); got != "" {
t.Errorf("out of range row: got %q, want empty", got)
}
}
func TestLogFileName(t *testing.T) {
cases := []struct{ path, want string }{
{"", ""},
{" ", ""},
{`C:\logs\run.log`, "run.log"},
{"/var/logs/2026/job.log", "job.log"},
{"plain.log", "plain.log"},
}
for _, tc := range cases {
if got := logFileName(tc.path); got != tc.want {
t.Errorf("logFileName(%q) = %q, want %q", tc.path, got, tc.want)
}
}
}
// TestHistorySortToggleKeepsRowsInSync is the regression guard for F11: the
// table now reads one cached sorted snapshot instead of re-sorting inside every
// cell callback, so the length callback and the cells have to be refilled
// together. If either the sort toggle or refresh stops calling resort(), the
// row count and the cell contents disagree — which no compiler check catches.
func TestHistorySortToggleKeepsRowsInSync(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
events := []event{
{Time: "2026-06-01 10:00:00", JobName: "A"},
{Time: "2026-06-01 11:00:00", JobName: "B"},
{Time: "2026-06-01 12:00:00", JobName: "C"},
}
log := newHistoryLog(events)
content, refresh := newHistoryView(log)
table, ok := content.Objects[0].(*widget.Table)
if !ok {
t.Fatal("history view does not wrap a table")
}
rowCount := func() int {
t.Helper()
rows, cols := table.Length()
if cols != len(historyHeaders) {
t.Errorf("column count = %d, want %d", cols, len(historyHeaders))
}
return rows
}
// Column 2 is the Job name, the field these fixtures vary.
jobAt := func(row int) string {
t.Helper()
cell := table.CreateCell()
table.UpdateCell(widget.TableCellID{Row: row, Col: 2}, cell)
return cell.(*widget.Label).Text
}
// The sort toggle lives on the Time header cell, which is only wired up
// when UpdateHeader runs for it.
header := table.CreateHeader()
table.UpdateHeader(widget.TableCellID{Row: -1, Col: 0}, header)
timeHeader, ok := header.(*historyHeader)
if !ok {
t.Fatal("history table header is not a historyHeader")
}
assertOrder := func(when string, want ...string) {
t.Helper()
if got := rowCount(); got != len(want) {
t.Fatalf("%s: row count = %d, want %d", when, got, len(want))
}
for row, name := range want {
if got := jobAt(row); got != name {
t.Errorf("%s: row %d = %q, want %q", when, row, got, name)
}
}
}
assertOrder("ascending", "A", "B", "C")
test.Tap(timeHeader)
assertOrder("descending", "C", "B", "A")
// A new run arrives while the table is sorted newest-first: it must be
// counted and placed in the order currently on screen, not the build-time one.
log.add(event{Time: "2026-06-01 13:00:00", JobName: "D"})
refresh()
assertOrder("descending after refresh", "D", "C", "B", "A")
test.Tap(timeHeader)
assertOrder("ascending after refresh", "A", "B", "C", "D")
}
// TestHistoryCellTemplateIsPlainText guards the dropped per-cell TextStyle
// assignment: the template must already carry the zero style, since nothing
// resets it any more.
func TestHistoryCellTemplateIsPlainText(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
content, _ := newHistoryView(newHistoryLog(nil))
table := content.Objects[0].(*widget.Table)
label, ok := table.CreateCell().(*widget.Label)
if !ok {
t.Fatal("history cell template is not a label")
}
if label.TextStyle != (fyne.TextStyle{}) {
t.Errorf("cell template TextStyle = %+v, want the zero value", label.TextStyle)
}
}
// TestTextColumnWidthClamps covers the three shapes textColumnWidth has to
// handle: a sample narrower than min, one that lands between the bounds, and
// one wide enough to hit the max cap.
func TestTextColumnWidthClamps(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
min, max := float32(50), float32(120)
if got := textColumnWidth([]string{"x"}, min, max); got != min {
t.Errorf("below-min sample: got %v, want the floor %v", got, min)
}
inRange := textWidth("mid-sized value") + cellPadding()
if inRange <= min || inRange >= max {
t.Skip("fixture sample no longer lands strictly between the bounds under this theme")
}
if got := textColumnWidth([]string{"mid-sized value"}, min, max); got != inRange {
t.Errorf("in-range sample: got %v, want %v", got, inRange)
}
if got := textColumnWidth([]string{strings.Repeat("0", 200)}, min, max); got != max {
t.Errorf("above-max sample: got %v, want the cap %v", got, max)
}
if got := textColumnWidth(nil, min, max); got != min {
t.Errorf("no samples: got %v, want the floor %v", got, min)
}
}
// TestHistoryColumnsFitTheirContent guards F6/F14: every column must be at
// least as wide as its widest known or actually-present value, at the default
// theme and at a scaled one, so nothing that used to be a pixel constant
// clips again.
func TestHistoryColumnsFitTheirContent(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
rows := []event{
{
Time: "2026-06-01 12:00:00",
Trigger: "Schedule",
JobName: "A moderately long job name for width testing",
State: "Jobs loaded",
Detail: "A somewhat longer detail message describing what happened",
LogFile: `/logs/20260601-120000_SomeJobName.log`,
},
}
check := func(when string) {
t.Helper()
widths := historyColumnWidths(rows)
samples := [][]string{
{historyTimeSample},
historyTriggerSamples,
{rows[0].JobName},
historyStateSamples,
{rows[0].Detail},
{logFileName(rows[0].LogFile)},
}
min, max := textColumnMinWidth(), textColumnMaxWidth()
for col, colSamples := range samples {
want := textColumnWidth(colSamples, min, max)
if col == 0 {
want = textWidth(historyTimeSample) + cellPadding()
}
if widths[col] < want {
t.Errorf("%s: column %d width = %v, want at least %v", when, col, widths[col], want)
}
}
}
check("default theme")
testApp.Settings().SetTheme(test.NewTheme())
check("scaled theme")
}
// TestHistoryLogCapsRecords is the regression guard for the unbounded History
// list: an app left in the tray records thousands of runs a day, each carrying
// the run's whole captured output, so the list must drop the oldest instead of
// growing forever.
func TestHistoryLogCapsRecords(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
log := newHistoryLog(nil)
for i := 0; i < maxHistoryRows+25; i++ {
log.add(event{Time: "t", JobName: "Job " + strconv.Itoa(i)})
}
if len(log.records) != maxHistoryRows {
t.Fatalf("record count = %d, want capped at %d", len(log.records), maxHistoryRows)
}
if got, want := log.records[0].JobName, "Job 25"; got != want {
t.Errorf("oldest kept record = %q, want %q — the cap must drop from the front", got, want)
}
last := log.records[len(log.records)-1].JobName
if want := "Job " + strconv.Itoa(maxHistoryRows+24); last != want {
t.Errorf("newest record = %q, want %q", last, want)
}
// A list handed in above the cap is trimmed too, not only one grown into it.
oversized := make([]event, maxHistoryRows+10)
if got := len(newHistoryLog(oversized).records); got != maxHistoryRows {
t.Errorf("pre-filled log length = %d, want %d", got, maxHistoryRows)
}
}
// TestHistoryLogWidthsMatchAFullScan pins the incremental column widths: while
// every measured record is still in the list, folding each one in as it
// arrives must give exactly what rescanning every row would, or the cheaper
// path would clip values the old one showed.
func TestHistoryLogWidthsMatchAFullScan(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
log := newHistoryLog(nil)
for _, record := range []event{
{Time: "1", JobName: "A", Detail: "short", LogFile: `/logs/a.log`},
{Time: "2", JobName: "A moderately long job name", Detail: "a longer detail message", LogFile: `/logs/20260601-120000_SomeJobName.log`},
{Time: "3", JobName: "B", Detail: "s", LogFile: `/logs/b.log`},
} {
log.add(record)
}
if got, want := log.columnWidths(), historyColumnWidths(log.records); got != want {
t.Errorf("incremental widths = %v, want the full-scan widths %v", got, want)
}
}
// TestHistoryLogWidthsDoNotShrinkWhenRecordsAgeOut covers the other half of the
// rule: widths only grow. Dropping the record that set a column's width must
// not narrow the column, because the rows on screen were laid out against it.
func TestHistoryLogWidthsDoNotShrinkWhenRecordsAgeOut(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
log := newHistoryLog(nil)
log.add(event{Time: "1", JobName: "A job name long enough to widen its column"})
widest := log.columnWidths()[2]
for i := 0; i < maxHistoryRows; i++ {
log.add(event{Time: "t", JobName: "x"})
}
if got := log.columnWidths()[2]; got != widest {
t.Errorf("Job column width = %v after the wide record aged out, want it held at %v", got, widest)
}
}
// TestHistoryLogRescansOnThemeChange guards the one case the incremental fold
// cannot handle: every stored width was measured at the old text size, so a
// theme change has to fall back to a full rescan.
func TestHistoryLogRescansOnThemeChange(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
log := newHistoryLog([]event{
{Time: "1", JobName: "A moderately long job name", Detail: "a longer detail message"},
})
before := log.columnWidths()
testApp.Settings().SetTheme(test.NewTheme())
after := log.columnWidths()
if after == before {
t.Fatal("widths unchanged after a theme change; the fixture theme must alter text metrics")
}
if want := historyColumnWidths(log.records); after != want {
t.Errorf("widths after theme change = %v, want the rescanned %v", after, want)
}
}
func TestNewEventUsesConsistentTimestampShape(t *testing.T) {
ev := newEvent(1, "Job", "OK", "detail")
if _, err := time.Parse("2006-01-02 15:04:05", ev.Time); err != nil {
t.Errorf("timestamp %q is not in expected layout: %v", ev.Time, err)
}
if ev.Trigger != "UI" || ev.JobID != 1 || ev.JobName != "Job" {
t.Errorf("unexpected event fields: %+v", ev)
}
}