Files
gosentry/src/ui/history_view_test.go
T
mixeme 57e6fe410e perf(ui): sort History once per redraw, drop duplicate jobs refreshes
Stage 4 of the GUI layout plan (F11, F12).

History: the cell callback copied and sorted the whole event list on every
call, and a full-window Refresh issues one call per visible cell — 126 sorts
of a 300-element slice per redraw, measured. The sorted snapshot now lives in
`rows`, refilled by `resort()` at build time, on a sort toggle, and from
`refresh()`. The length callback moves to `len(rows)` with it: cells and the
row count have to read the same slice, which was only incidentally true while
each cell re-derived the order for itself. The column captions become a
package-level array instead of a slice reallocated per header update, and the
per-cell `TextStyle`/`Refresh()` pair goes — the template already carries the
zero style and `SetText` refreshes.

Jobs: `refreshView()` already re-reads the service snapshot and refreshes the
list, so the six `list.Refresh()` calls that preceded it, and the duplicate
`syncFromService()` in the pause handler, were redundant. The folder filter's
early-return path never reaches `refreshView()`, so its `list.Refresh()` moves
into that branch rather than being deleted.

Both changes carry regression tests, each verified to fail against the
behaviour it guards: `TestHistorySortToggleKeepsRowsInSync` for the
cache-versus-length hazard, `TestToolbarButtonRedrawsRowAndDetails` for the
handlers that now rely on `refreshView` alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:09:51 +03:00

230 lines
6.5 KiB
Go

package ui
import (
"testing"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"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 TestCollectActivityMergesAndSorts(t *testing.T) {
jobs := []job{
{ID: 1, Name: "A"},
{ID: 2, Name: "B"},
}
runtimes := map[int]*domain.JobRuntime{
1: {Logs: []domain.RunRecord{{Time: "2026-01-02 10:00:00", JobID: 1}}},
2: {Logs: []domain.RunRecord{{Time: "2026-01-01 09:00:00", JobID: 2}}},
}
got := collectActivity(jobs, runtimes)
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got[0].Time != "2026-01-01 09:00:00" || got[1].Time != "2026-01-02 10:00:00" {
t.Errorf("sort order = %v, want ascending by Time", got)
}
}
func TestCollectActivitySkipsMissingRuntimes(t *testing.T) {
jobs := []job{{ID: 1, Name: "A"}}
if got := collectActivity(jobs, nil); len(got) != 0 {
t.Errorf("nil runtimes: got %v, want empty", 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"},
}
content, refresh := newHistoryView(&events)
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.
events = append(events, 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()
var events []event
content, _ := newHistoryView(&events)
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)
}
}
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)
}
}