From cf2af1d464329dc72c2f70151c5d057032c32c54 Mon Sep 17 00:00:00 2001 From: mixeme Date: Fri, 19 Jun 2026 08:30:42 +0300 Subject: [PATCH] @ T3.6: Add src/app unit tests (no Fyne) Fill the test gaps in the app package, raising coverage from 82% to 97%: - format_test.go: cover all display helpers moved in T3.5 (StatusText, EventText, Display*), previously untested. - operations_test.go: add RunNow not-found and refused-while-running paths, DeleteJob/SetEnabled not-found errors, UpdateJob re-enable, runtimeForLocked lazy-recreate, UpdateSettings invalid-config branches, and prependLog activity-list capping. All tests are Fyne-free, using a temp-dir store, a fake runner seam, and a fake Clock. go vet and go test -race pass across the module. Co-Authored-By: Claude Opus 4.8 @ --- docs/REFACTORING.md | 2 +- src/app/format_test.go | 109 ++++++++++++++++++++++++++++++ src/app/operations_test.go | 134 +++++++++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 src/app/format_test.go diff --git a/docs/REFACTORING.md b/docs/REFACTORING.md index 55176cd..ad085a9 100644 --- a/docs/REFACTORING.md +++ b/docs/REFACTORING.md @@ -265,7 +265,7 @@ Track progress here. Mark tasks complete as they land and pass review. - [x] T3.3 — Add state-mutating operations to service - [x] T3.4 — Convert `scheduler` to use service; inject Clock - [x] T3.5 — Move display helpers to `src/app/format.go` -- [ ] T3.6 — Add `src/app` unit tests (no Fyne) +- [x] T3.6 — Add `src/app` unit tests (no Fyne) ### Phase 4 — Carve up the GUI - [ ] T4.1 — Rename `gui` → `ui`; split app.go into run.go + mainwindow.go diff --git a/src/app/format_test.go b/src/app/format_test.go new file mode 100644 index 0000000..016bd7b --- /dev/null +++ b/src/app/format_test.go @@ -0,0 +1,109 @@ +package app + +import ( + "testing" + + "gitea.mixdep.ru/mix/gosentry/src/domain" +) + +func TestStatusText(t *testing.T) { + tests := []struct { + name string + job domain.Job + runtime *domain.JobRuntime + want string + }{ + {"disabled is paused", domain.Job{Enabled: false}, &domain.JobRuntime{LastState: "Running"}, "Paused"}, + {"enabled shows runtime state", domain.Job{Enabled: true}, &domain.JobRuntime{LastState: "Success"}, "Success"}, + {"enabled with nil runtime is empty", domain.Job{Enabled: true}, nil, ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := StatusText(tc.job, tc.runtime); got != tc.want { + t.Errorf("StatusText = %q, want %q", got, tc.want) + } + }) + } +} + +func TestEventText(t *testing.T) { + withLog := domain.RunRecord{ + Time: "2026-06-19 12:00:00", Trigger: "Schedule", JobName: "Build", + State: "Success", Detail: "ok", LogFile: "build.log", + } + if got, want := EventText(withLog), "2026-06-19 12:00:00 Schedule Build Success ok build.log"; got != want { + t.Errorf("EventText with log = %q, want %q", got, want) + } + + noLog := domain.RunRecord{ + Time: "2026-06-19 12:00:00", Trigger: "Manual", JobName: "Build", + State: "Success", Detail: "ok", + } + if got, want := EventText(noLog), "2026-06-19 12:00:00 Manual Build Success ok"; got != want { + t.Errorf("EventText without log = %q, want %q", got, want) + } + + // An empty trigger is shown as "Unknown". + blank := domain.RunRecord{Time: "t", JobName: "J", State: "S", Detail: "d"} + if got, want := EventText(blank), "t Unknown J S d"; got != want { + t.Errorf("EventText blank trigger = %q, want %q", got, want) + } +} + +func TestDisplayFolder(t *testing.T) { + if got := DisplayFolder(" "); got != "(No folder)" { + t.Errorf("blank folder = %q, want %q", got, "(No folder)") + } + if got := DisplayFolder(" Reports "); got != "Reports" { + t.Errorf("folder = %q, want %q", got, "Reports") + } +} + +func TestDisplayArguments(t *testing.T) { + if got := DisplayArguments(""); got != "(none)" { + t.Errorf("empty args = %q, want %q", got, "(none)") + } + if got := DisplayArguments(" -v "); got != "-v" { + t.Errorf("args = %q, want %q", got, "-v") + } +} + +func TestDisplaySuccessExitCodes(t *testing.T) { + if got := DisplaySuccessExitCodes(" "); got != "0" { + t.Errorf("empty codes = %q, want %q", got, "0") + } + if got := DisplaySuccessExitCodes(" 0,1 "); got != "0,1" { + t.Errorf("codes = %q, want %q", got, "0,1") + } +} + +func TestDisplayRunMode(t *testing.T) { + if got := DisplayRunMode(domain.Job{StartOnly: true}); got != "Start only" { + t.Errorf("start-only = %q, want %q", got, "Start only") + } + if got := DisplayRunMode(domain.Job{StartOnly: false}); got != "Wait for completion" { + t.Errorf("wait = %q, want %q", got, "Wait for completion") + } +} + +func TestDisplayInvocation(t *testing.T) { + if got := DisplayInvocation(domain.Job{Command: "echo"}); got != "echo" { + t.Errorf("no args = %q, want %q", got, "echo") + } + // Arguments are appended with spacing and their newlines collapsed to spaces. + job := domain.Job{Command: "echo", Arguments: " hi\nthere "} + if got, want := DisplayInvocation(job), "echo hi there"; got != want { + t.Errorf("with args = %q, want %q", got, want) + } +} + +func TestDisplayIndex(t *testing.T) { + indexes := []int{4, 7, 2} + if got := DisplayIndex(indexes, 7); got != 1 { + t.Errorf("DisplayIndex(7) = %d, want 1", got) + } + // A jobIndex not present returns 0. + if got := DisplayIndex(indexes, 99); got != 0 { + t.Errorf("DisplayIndex(missing) = %d, want 0", got) + } +} diff --git a/src/app/operations_test.go b/src/app/operations_test.go index 62fac74..d9345b4 100644 --- a/src/app/operations_test.go +++ b/src/app/operations_test.go @@ -114,6 +114,37 @@ func TestUpdateJobKeepsRuntimeAndReflectsDisable(t *testing.T) { } } +func TestUpdateJobReenablesPausedJob(t *testing.T) { + svc := newTempService(t, []domain.Job{{ID: 5, Name: "Old", Schedule: "@every 1m", Command: "echo", Enabled: false}}) + if rt := svc.Runtime(5); rt.LastState != "Paused" { + t.Fatalf("precondition: runtime = %+v, want Paused", rt) + } + if err := svc.UpdateJob(domain.Job{ID: 5, Name: "Old", Schedule: "@every 1m", Command: "echo", Enabled: true}); err != nil { + t.Fatalf("UpdateJob: %v", err) + } + if rt := svc.Runtime(5); rt.LastState != "Ready" || rt.NextDue.IsZero() { + t.Errorf("re-enabled runtime = %+v, want Ready with a next-due", rt) + } +} + +// runtimeForLocked lazily recreates a missing runtime entry so the Service stays +// robust if a job somehow lacks one. Dropping the entry and driving an operation +// that needs it exercises that path. +func TestRuntimeLazilyRecreated(t *testing.T) { + svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) + + svc.mu.Lock() + delete(svc.runtimes, 1) + svc.mu.Unlock() + + if err := svc.SetEnabled(1, true); err != nil { + t.Fatalf("SetEnabled: %v", err) + } + if rt := svc.Runtime(1); rt == nil { + t.Error("runtime was not lazily recreated") + } +} + func TestUpdateJobNotFound(t *testing.T) { svc := newTempService(t, nil) if err := svc.UpdateJob(domain.Job{ID: 99, Name: "X", Schedule: "@every 1m", Command: "echo"}); err == nil { @@ -143,6 +174,20 @@ func TestDeleteJobRemovesEverything(t *testing.T) { } } +func TestDeleteJobNotFound(t *testing.T) { + svc := newTempService(t, nil) + if err := svc.DeleteJob(42); err == nil { + t.Error("expected not-found error deleting unknown job") + } +} + +func TestSetEnabledNotFound(t *testing.T) { + svc := newTempService(t, nil) + if err := svc.SetEnabled(42, true); err == nil { + t.Error("expected not-found error enabling unknown job") + } +} + func TestSetEnabledToggles(t *testing.T) { svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: false}}) @@ -233,6 +278,61 @@ func TestRunNowUsesRunnerAndRecords(t *testing.T) { } } +func TestRunNowNotFound(t *testing.T) { + svc := newTempService(t, nil) + if err := svc.RunNow(99); err == nil { + t.Error("expected not-found error for unknown job") + } +} + +func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) { + svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) + + // Park the job in the "Running" state so a second RunNow must refuse: the + // runner signals once it has started and then blocks until released. + entered := make(chan struct{}, 1) + release := make(chan struct{}) + var calls int32 + svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord { + atomic.AddInt32(&calls, 1) + entered <- struct{}{} + <-release + return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success"} + } + done := make(chan struct{}, 1) + svc.Subscribe(ObserverFunc(func(e Event) { + if rr, ok := e.(RunRecorded); ok && rr.Record.State == "Success" { + select { + case done <- struct{}{}: + default: + } + } + })) + + if err := svc.RunNow(1); err != nil { + t.Fatalf("first RunNow: %v", err) + } + <-entered // the run is now in-flight and blocked + + if err := svc.RunNow(1); err == nil { + t.Error("expected RunNow to be refused while already running") + } + close(release) + + // Wait for the in-flight run to finish before returning so its background + // writes complete before t.TempDir cleanup removes the directory. + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for the in-flight run to complete") + } + + // Only the first run should ever have reached the runner. + if got := atomic.LoadInt32(&calls); got != 1 { + t.Errorf("runner called %d times, want 1", got) + } +} + func TestRunNowRefusedWhilePaused(t *testing.T) { svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) var ran bool @@ -371,3 +471,37 @@ func TestUpdateSettingsPersistsAndValidates(t *testing.T) { t.Errorf("config not applied: %+v", svc.Store().Config) } } + +func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) { + svc := newTempService(t, nil) + base := svc.store.Config + + tests := []struct { + name string + mutate func(c *domain.Config) + }{ + {"missing jobs dir", func(c *domain.Config) { c.JobsDir = " " }}, + {"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }}, + {"non-positive max files", func(c *domain.Config) { c.MaxLogFiles = 0 }}, + {"non-positive max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := base + tc.mutate(&cfg) + if err := svc.UpdateSettings(cfg); err == nil { + t.Errorf("expected validation error for %s", tc.name) + } + }) + } +} + +func TestPrependLogCapsActivityList(t *testing.T) { + runtime := &domain.JobRuntime{} + for i := 0; i < maxJobLogs+10; i++ { + prependLog(runtime, domain.RunRecord{Detail: "r"}) + } + if len(runtime.Logs) != maxJobLogs { + t.Errorf("activity list len = %d, want capped at %d", len(runtime.Logs), maxJobLogs) + } +}