Refactoring complete: v0.4.0 architectural milestone #1
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user