docs: add FUTURE_WORK quality standard and close polish gaps

Replace CODE_REVIEW.md with a living maturity checklist, document
session-only History, inject Service into newMainView for testability,
add UI and scheduler regression tests, and fix RunNow error surfacing
plus empty jobs view handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mixeme
2026-07-01 23:14:09 +03:00
parent eba7bff17a
commit aed83b91b9
14 changed files with 576 additions and 69 deletions
+138
View File
@@ -0,0 +1,138 @@
package ui
import (
"testing"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"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)
}
}
}
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)
}
}
+13 -4
View File
@@ -65,11 +65,19 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
}
selected := 0
if len(jobs) == 0 {
selected = -1
}
selectedFolder := allFolders
schedulerPaused := svc.Store().Config.Paused
filteredJobs := filteredJobIndexes(jobs, selectedFolder)
dp := newDetailsPanel(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy)
dp := newDetailsPanel(job{}, &domain.JobRuntime{}, svc.Store().Config.OverlapPolicy)
if selected >= 0 {
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy)
} else {
dp.clear()
}
updateDetails := func(index int) {
if index < 0 || index >= len(jobs) {
@@ -126,7 +134,9 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
}
updateDetails(filteredJobs[id])
}
list.Select(selected)
if len(filteredJobs) > 0 && selected >= 0 {
list.Select(app.DisplayIndex(filteredJobs, selected))
}
folderSelect = widget.NewSelect(folderOptions(jobs), func(value string) {
if value == "" {
@@ -193,9 +203,8 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
}
// A manual run is allowed even while the scheduler is paused: pause only
// stops automatic scheduled runs, not the user's explicit "Run now".
// RunNow still refuses an already-running job (it returns an error); the UI
// has always ignored that case silently, so the run simply does not start.
if err := svc.RunNow(jobs[selected].ID); err != nil {
dialog.ShowError(err, w)
return
}
list.Refresh()
+1 -1
View File
@@ -53,5 +53,5 @@ func indexOfID(jobs []job, id int) int {
return index
}
}
return 0
return -1
}
+1 -6
View File
@@ -10,7 +10,6 @@ import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
// The UI package aliases domain types to keep widget callbacks short. The actual
@@ -19,11 +18,7 @@ import (
type job = domain.Job
type event = domain.RunRecord
func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
svc, err := app.Open()
if err != nil {
return container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())), func(time.Duration, bool) {}
}
func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.Duration, bool)) {
svc.InstallDesktopIcon(appID, assets.IconBytes())
// Build the initial event history from the current runtime state. Jobs and
+56
View File
@@ -0,0 +1,56 @@
package ui
import (
"path/filepath"
"testing"
"gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/storage"
"fyne.io/fyne/v2/test"
)
func newTestService(t *testing.T) *app.Service {
t.Helper()
dir := t.TempDir()
store := &storage.Store{
Paths: storage.Paths{
ExecutablePath: filepath.Join(dir, "gosentry"),
AppDir: dir,
ConfigPath: filepath.Join(dir, "gosentry.json"),
JobsDir: dir,
JobsPath: filepath.Join(dir, "jobs.json"),
LogsDir: filepath.Join(dir, "logs"),
},
Config: domain.Config{
JobsDir: ".",
LogsDir: "logs",
MaxLogFiles: 100,
MaxLogAgeDays: 30,
ExecutionMode: domain.ExecutionModeParallel,
OverlapPolicy: domain.OverlapPolicySkip,
KeepRunningInTray: true,
NotifyOnFailure: true,
},
}
return app.NewService(store, nil)
}
func TestMainViewBuilds(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
w := testApp.NewWindow("test")
defer w.Close()
svc := newTestService(t)
defer svc.Stop()
content, recordStartup := newMainView(w, svc)
if content == nil {
t.Fatal("newMainView returned nil content")
}
w.SetContent(content)
recordStartup(0, true)
}
+9 -1
View File
@@ -9,6 +9,8 @@ import (
"fyne.io/fyne/v2"
fyneapp "fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
const appID = "ru.mixeme.gosentry.desktop"
@@ -52,7 +54,13 @@ func Run(startInTray bool) {
winW := float32(prefs.FloatWithFallback("window.width", 1024))
winH := float32(prefs.FloatWithFallback("window.height", 660))
w.Resize(fyne.NewSize(winW, winH))
content, recordStartup := newMainView(w)
svc, err := app.Open()
if err != nil {
w.SetContent(container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())))
a.Run()
return
}
content, recordStartup := newMainView(w, svc)
w.SetContent(content)
serveSingleInstance(instanceListener, w)
if startInTray {