Refactoring complete: v0.4.0 architectural milestone (#1)

## Summary

Completed Phase 5 refactoring and reached the target architecture.

**Architectural milestone achieved:**
- Service layer owns all state and is the sole writer
- UI is a thin Fyne view, all widget updates marshaled via `fyne.Do`
- Core engines are stateless and injectable
- Domain types are pure (no `yaml:"-"` fields)
- Full module builds and `go vet ./...` clean

## Changes

- Bump version: 0.3.6 → 0.4.0
- Update CHANGELOG with Phase 5 summary
- Add ROADMAP "Refactoring Follow-Ups" section

## Known follow-up work

1. **Linux test build broken** — `runner_test.go` needs `//go:build windows` tag
2. **File-size limits exceeded** — `operations.go` (486 lines), `jobs_view.go` (415 lines)

See ROADMAP.md for details.

---------

Co-authored-by: mixeme <mix.public@ya.ru>
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-06-22 08:05:10 +03:00
parent d24211cab2
commit 01fd572a89
74 changed files with 5424 additions and 2712 deletions
+103
View File
@@ -0,0 +1,103 @@
package domain
import (
"testing"
"time"
)
func TestParseRejectsInvalidSchedules(t *testing.T) {
cases := []struct {
spec string
desc string
}{
{"", "empty string"},
{" ", "whitespace only"},
{"@every", "bare @every without duration"},
{"@every ", "@every with trailing space but no duration"},
{"@every xyz", "invalid @every duration string"},
{"@every -1s", "negative @every duration"},
{"@every 0s", "zero @every duration"},
{"not-a-cron", "invalid cron expression"},
{"60 * * * *", "cron minute out of range"},
{"* * * *", "too few cron fields"},
}
for _, tc := range cases {
if _, err := Parse(tc.spec); err == nil {
t.Errorf("Parse(%q) [%s]: expected error, got nil", tc.spec, tc.desc)
}
if err := Validate(tc.spec); err == nil {
t.Errorf("Validate(%q) [%s]: expected error, got nil", tc.spec, tc.desc)
}
}
}
func TestParseEveryInterval(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
s, err := Parse("@every 10s")
if err != nil {
t.Fatalf("Parse(@every 10s): unexpected error: %v", err)
}
if got, want := s.Next(from), from.Add(10*time.Second); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestParseEveryTrimsSurroundingWhitespace(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
s, err := Parse(" @every 90m ")
if err != nil {
t.Fatalf("Parse: unexpected error: %v", err)
}
if got, want := s.Next(from), from.Add(90*time.Minute); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestParseCronExpression(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
s, err := Parse("*/5 * * * *")
if err != nil {
t.Fatalf("Parse(*/5 * * * *): unexpected error: %v", err)
}
want := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
if got := s.Next(from); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestParseCronDescriptor(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
s, err := Parse("@daily")
if err != nil {
t.Fatalf("Parse(@daily): unexpected error: %v", err)
}
want := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
if got := s.Next(from); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestValidateAcceptsValidSchedules(t *testing.T) {
for _, spec := range []string{"@every 1s", "*/5 * * * *", "0 9 * * 1", "@hourly"} {
if err := Validate(spec); err != nil {
t.Errorf("Validate(%q): unexpected error: %v", spec, err)
}
}
}
func TestZeroScheduleNextIsZero(t *testing.T) {
var s Schedule
if got := s.Next(time.Now()); !got.IsZero() {
t.Fatalf("zero Schedule Next: got %s, want zero time", got)
}
}
func TestStringReturnsTrimmedSpec(t *testing.T) {
s, err := Parse(" */5 * * * * ")
if err != nil {
t.Fatalf("Parse: unexpected error: %v", err)
}
if got, want := s.String(), "*/5 * * * *"; got != want {
t.Fatalf("String: got %q, want %q", got, want)
}
}