Refactoring complete: v0.4.0 architectural milestone #1

Merged
mix merged 48 commits from docs/refactoring-plan into main 2026-06-22 08:05:10 +03:00
3 changed files with 244 additions and 1 deletions
Showing only changes of commit 6073d5e112 - Show all commits
+1 -1
View File
@@ -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.3 — Add state-mutating operations to service
- [x] T3.4 — Convert `scheduler` to use service; inject Clock - [x] T3.4 — Convert `scheduler` to use service; inject Clock
- [x] T3.5 — Move display helpers to `src/app/format.go` - [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 ### Phase 4 — Carve up the GUI
- [ ] T4.1 — Rename `gui``ui`; split app.go into run.go + mainwindow.go - [ ] T4.1 — Rename `gui``ui`; split app.go into run.go + mainwindow.go
+109
View File
@@ -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)
}
}
+134
View File
@@ -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) { func TestUpdateJobNotFound(t *testing.T) {
svc := newTempService(t, nil) svc := newTempService(t, nil)
if err := svc.UpdateJob(domain.Job{ID: 99, Name: "X", Schedule: "@every 1m", Command: "echo"}); err == 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) { func TestSetEnabledToggles(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: false}}) 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) { func TestRunNowRefusedWhilePaused(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
var ran bool var ran bool
@@ -371,3 +471,37 @@ func TestUpdateSettingsPersistsAndValidates(t *testing.T) {
t.Errorf("config not applied: %+v", svc.Store().Config) 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)
}
}