feat: add a compact job list view to the Jobs tab

Each job can now render as a single line — name on the left, status on the
right — instead of the three-line block, so many more jobs fit without
scrolling. A toggle button beside the Folder filter switches between the two
modes and is labelled with the action it performs, matching the existing
"Disable auto" convention.

The choice is persisted as Config.JobListView ("detailed" / "compact", stored
as job_list_view in gosentry.json). Empty, legacy, and unrecognised values all
normalize to detailed, so existing installs keep the current look and the file
never gains a value no reader understands.

Selection, the details panel, the folder filter, and live status updates work
unchanged in both modes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-07-26 22:14:06 +03:00
parent e85cbc4eb1
commit 29ce94c3e8
11 changed files with 433 additions and 9 deletions
+1 -1
View File
@@ -203,4 +203,4 @@ size guideline:
|------|----------| |------|----------|
| `jobs_view.go` | `newJobsView` — list, toolbar, button wiring, and layout | | `jobs_view.go` | `newJobsView` — list, toolbar, button wiring, and layout |
| `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` | | `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` |
| `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs` | | `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs`, `nextJobListView`, `viewToggleText` |
+13
View File
@@ -4,6 +4,19 @@ All notable GoSentry changes are recorded in this file.
## Unreleased ## Unreleased
**Compact job list view.**
- The Jobs sidebar can now render each job as a single line — name on the left,
status on the right — instead of the three-line block. A toggle button beside
the Folder filter switches between **Compact** and **Detailed**; it is
labelled with the action it performs, like the "Disable auto" button. Compact
fits many more jobs on screen without scrolling; selection, the details panel,
the folder filter, and live status updates all work unchanged in both modes.
- The choice is persisted as a new `Config.JobListView` field
(`"detailed"` / `"compact"`, written to `gosentry.json` as `job_list_view`),
so it survives a restart. Empty/legacy configs and any unrecognised value
normalize to detailed, so existing installs keep the current look.
**Timeouts: 0 now means "no timeout" at both levels.** **Timeouts: 0 now means "no timeout" at both levels.**
- The global **Default timeout** in Settings now defaults to `0`, meaning jobs - The global **Default timeout** in Settings now defaults to `0`, meaning jobs
+21
View File
@@ -193,6 +193,27 @@ func (s *Service) SetGlobalPause(paused bool) error {
return nil return nil
} }
// SetJobListView persists the Jobs list density preference. Unlike
// SetGlobalPause this touches nothing but the config: no job changed, so there
// is no SaveJobs, and no event is emitted — the choice is presentational and the
// Jobs view refreshes its own list, whereas an event would trigger a pointless
// whole-window refresh. Anything that is not "compact" is stored as detailed so
// the file never gains an unrecognised value.
func (s *Service) SetJobListView(view domain.JobListView) error {
if !view.IsCompact() {
view = domain.JobListViewDetailed
}
s.mu.Lock()
if s.store.Config.JobListView == view {
s.mu.Unlock()
return nil
}
s.store.Config.JobListView = view
err := s.store.SaveConfig()
s.mu.Unlock()
return err
}
// ShouldNotifyOnFailure reports whether the user has enabled desktop // ShouldNotifyOnFailure reports whether the user has enabled desktop
// notifications for failed job runs. It reads the config under mu so it is // notifications for failed job runs. It reads the config under mu so it is
// safe to call from any goroutine. // safe to call from any goroutine.
+57
View File
@@ -590,6 +590,63 @@ func TestSetGlobalPausePersistsToConfigFile(t *testing.T) {
} }
} }
func TestSetJobListViewPersistsToConfigFile(t *testing.T) {
svc := newTempService(t, nil)
readConfig := func(stage string) domain.Config {
t.Helper()
data, err := os.ReadFile(svc.store.Paths.ConfigPath)
if err != nil {
t.Fatalf("reading config file %s: %v", stage, err)
}
var cfg domain.Config
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("unmarshalling config %s: %v", stage, err)
}
return cfg
}
if err := svc.SetJobListView(domain.JobListViewCompact); err != nil {
t.Fatalf("SetJobListView(compact): %v", err)
}
if got := readConfig("after compact").JobListView; got != domain.JobListViewCompact {
t.Errorf("persisted JobListView = %q, want %q", got, domain.JobListViewCompact)
}
if err := svc.SetJobListView(domain.JobListViewDetailed); err != nil {
t.Fatalf("SetJobListView(detailed): %v", err)
}
if got := readConfig("after detailed").JobListView; got != domain.JobListViewDetailed {
t.Errorf("persisted JobListView = %q, want %q", got, domain.JobListViewDetailed)
}
}
// TestSetJobListViewNormalizesUnknownValue guards the config file against
// gaining a value no reader understands: anything but "compact" is stored as
// "detailed".
func TestSetJobListViewNormalizesUnknownValue(t *testing.T) {
svc := newTempService(t, nil)
if err := svc.SetJobListView(domain.JobListViewCompact); err != nil {
t.Fatalf("SetJobListView(compact): %v", err)
}
if err := svc.SetJobListView("tiny"); err != nil {
t.Fatalf("SetJobListView(tiny): %v", err)
}
data, err := os.ReadFile(svc.store.Paths.ConfigPath)
if err != nil {
t.Fatalf("reading config file: %v", err)
}
var cfg domain.Config
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("unmarshalling config: %v", err)
}
if cfg.JobListView != domain.JobListViewDetailed {
t.Errorf("persisted JobListView = %q, want %q", cfg.JobListView, domain.JobListViewDetailed)
}
}
func TestServiceRebuiltFromPausedStoreStartsPaused(t *testing.T) { func TestServiceRebuiltFromPausedStoreStartsPaused(t *testing.T) {
jobs := []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}} jobs := []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}
svc := newTempService(t, jobs) svc := newTempService(t, jobs)
+25
View File
@@ -28,6 +28,26 @@ const (
ThemeGoSentry Theme = "gosentry" ThemeGoSentry Theme = "gosentry"
) )
// JobListView selects how densely the Jobs tab renders its sidebar list. Like
// Theme it is a UI-only choice with no effect on scheduling; it lives in Config
// so the user's preference survives a restart.
type JobListView string
const (
// JobListViewDetailed is the three-line row: name, metadata, status.
JobListViewDetailed JobListView = "detailed"
// JobListViewCompact is the one-line row: name on the left, status on the
// right, so many more jobs fit without scrolling.
JobListViewCompact JobListView = "compact"
)
// IsCompact reports whether the compact rendering is selected. Only the exact
// "compact" value counts, so empty, legacy, and unrecognised values all read as
// detailed — every consumer normalizes them the same way.
func (v JobListView) IsCompact() bool {
return v == JobListViewCompact
}
// OverlapPolicy decides what happens when a job's next run fires while the // OverlapPolicy decides what happens when a job's next run fires while the
// previous run is still active. // previous run is still active.
type OverlapPolicy string type OverlapPolicy string
@@ -62,6 +82,10 @@ type Config struct {
// Theme selects the visual appearance. Empty is treated as ThemeDefault so // Theme selects the visual appearance. Empty is treated as ThemeDefault so
// configs written before this field existed keep the original look. // configs written before this field existed keep the original look.
Theme Theme `json:"theme,omitempty"` Theme Theme `json:"theme,omitempty"`
// JobListView selects the Jobs list density. Empty is treated as
// JobListViewDetailed so configs written before this field existed keep the
// current three-line rows.
JobListView JobListView `json:"job_list_view,omitempty"`
} }
// DefaultConfig returns the built-in default settings. It is the config used // DefaultConfig returns the built-in default settings. It is the config used
@@ -79,6 +103,7 @@ func DefaultConfig() Config {
ExecutionMode: ExecutionModeParallel, ExecutionMode: ExecutionModeParallel,
OverlapPolicy: OverlapPolicySkip, OverlapPolicy: OverlapPolicySkip,
Theme: ThemeDefault, Theme: ThemeDefault,
JobListView: JobListViewDetailed,
DefaultTimeoutSeconds: 0, DefaultTimeoutSeconds: 0,
} }
} }
+30
View File
@@ -0,0 +1,30 @@
package domain
import "testing"
// TestJobListViewIsCompact pins the normalization rule: only the exact
// "compact" value selects the one-line rows, so empty and unrecognised values
// (including configs written before the field existed) keep the detailed look.
func TestJobListViewIsCompact(t *testing.T) {
cases := []struct {
view JobListView
want bool
}{
{JobListViewCompact, true},
{JobListViewDetailed, false},
{"", false},
{"Compact", false},
{"tiny", false},
}
for _, tc := range cases {
if got := tc.view.IsCompact(); got != tc.want {
t.Errorf("JobListView(%q).IsCompact() = %v, want %v", tc.view, got, tc.want)
}
}
}
func TestDefaultConfigUsesDetailedJobList(t *testing.T) {
if got := DefaultConfig().JobListView; got != JobListViewDetailed {
t.Errorf("default JobListView = %q, want %q", got, JobListViewDetailed)
}
}
+3
View File
@@ -174,6 +174,9 @@ func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
if got.Theme != domain.ThemeDefault { if got.Theme != domain.ThemeDefault {
t.Errorf("default Theme = %q, want %q", got.Theme, domain.ThemeDefault) t.Errorf("default Theme = %q, want %q", got.Theme, domain.ThemeDefault)
} }
if got.JobListView != domain.JobListViewDetailed {
t.Errorf("default JobListView = %q, want %q", got.JobListView, domain.JobListViewDetailed)
}
// The function must have written the defaults to gosentry.json. // The function must have written the defaults to gosentry.json.
if _, err := os.Stat(paths.ConfigPath); err != nil { if _, err := os.Stat(paths.ConfigPath); err != nil {
t.Errorf("gosentry.json should have been created: %v", err) t.Errorf("gosentry.json should have been created: %v", err)
+67 -4
View File
@@ -70,6 +70,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
} }
selectedFolder := allFolders selectedFolder := allFolders
schedulerPaused := svc.Store().Config.Paused schedulerPaused := svc.Store().Config.Paused
listView := svc.Store().Config.JobListView
filteredJobs := filteredJobIndexes(jobs, selectedFolder) filteredJobs := filteredJobIndexes(jobs, selectedFolder)
dp := newDetailsPanel(job{}, &domain.JobRuntime{}, svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds) dp := newDetailsPanel(job{}, &domain.JobRuntime{}, svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
@@ -105,17 +106,44 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
} }
} }
// applyRowMode expresses the current view mode as visibility on the row's
// four labels. widget.List caches the row template's MinSize, and
// list.Refresh() re-creates the template and recomputes it, so hiding lines
// is what actually shrinks the rows: compactVBoxLayout and the border layout
// both skip hidden children when measuring.
applyRowMode := func(inlineStatus, meta, status fyne.CanvasObject) {
if listView.IsCompact() {
inlineStatus.Show()
meta.Hide()
status.Hide()
return
}
inlineStatus.Hide()
meta.Show()
status.Show()
}
list = widget.NewList( list = widget.NewList(
func() int { return len(filteredJobs) }, func() int { return len(filteredJobs) },
func() fyne.CanvasObject { func() fyne.CanvasObject {
name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
// Truncating stops a long name from pushing the compact row's status
// off the right-hand edge.
name.Wrapping = fyne.TextTruncate
inlineStatus := widget.NewLabel("status")
meta := widget.NewLabel("schedule") meta := widget.NewLabel("schedule")
status := widget.NewLabel("status") status := widget.NewLabel("status")
return container.New(compactVBoxLayout{spacing: jobRowSpacing}, name, meta, status) applyRowMode(inlineStatus, meta, status)
nameLine := container.NewBorder(nil, nil, nil, inlineStatus, name)
return container.New(compactVBoxLayout{spacing: jobRowSpacing}, nameLine, meta, status)
}, },
func(id widget.ListItemID, item fyne.CanvasObject) { func(id widget.ListItemID, item fyne.CanvasObject) {
row := item.(*fyne.Container) row := item.(*fyne.Container)
name := row.Objects[0].(*widget.Label) // NewBorder keeps the center object first and appends the border slots
// after it, so nameLine is [name, inlineStatus].
nameLine := row.Objects[0].(*fyne.Container)
name := nameLine.Objects[0].(*widget.Label)
inlineStatus := nameLine.Objects[1].(*widget.Label)
meta := row.Objects[1].(*widget.Label) meta := row.Objects[1].(*widget.Label)
status := row.Objects[2].(*widget.Label) status := row.Objects[2].(*widget.Label)
@@ -124,7 +152,12 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
// Keep each row compact: folder, schedule, and command are shown in one // Keep each row compact: folder, schedule, and command are shown in one
// metadata line so the left pane stays useful even with many jobs. // metadata line so the left pane stays useful even with many jobs.
meta.SetText(app.DisplayFolder(current.Folder) + " " + current.Schedule + " " + app.DisplayInvocation(current)) meta.SetText(app.DisplayFolder(current.Folder) + " " + current.Schedule + " " + app.DisplayInvocation(current))
status.SetText(app.StatusText(current, runtimes[current.ID])) statusText := app.StatusText(current, runtimes[current.ID])
status.SetText(statusText)
inlineStatus.SetText(statusText)
// A full Refresh reuses rows built under the previous mode, so
// visibility cannot be left to the create callback alone.
applyRowMode(inlineStatus, meta, status)
}, },
) )
list.OnSelected = func(id widget.ListItemID) { list.OnSelected = func(id widget.ListItemID) {
@@ -158,6 +191,32 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
}) })
folderSelect.SetSelected(selectedFolder) folderSelect.SetSelected(selectedFolder)
// viewToggleIcon pairs with viewToggleText: both name the action the button
// performs, not the state it is in, matching stopAllButton's convention.
viewToggleIcon := func(current domain.JobListView) fyne.Resource {
if current.IsCompact() {
return theme.ViewFullScreenIcon()
}
return theme.ListIcon()
}
viewButton := widget.NewButtonWithIcon(viewToggleText(listView), viewToggleIcon(listView), nil)
viewButton.OnTapped = func() {
next := nextJobListView(listView)
listView = next
if err := svc.SetJobListView(next); err != nil {
// Roll the mode back and leave the button as it was, so the button
// never claims a preference that did not reach disk.
listView = nextJobListView(next)
dialog.ShowError(err, w)
return
}
viewButton.SetText(viewToggleText(listView))
viewButton.SetIcon(viewToggleIcon(listView))
// Refresh re-creates the row template, which is what recomputes the
// cached row height for the new mode. Selection is untouched.
list.Refresh()
}
addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() { addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true}, func(saved job) { showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true}, func(saved job) {
created, err := svc.CreateJob(saved) created, err := svc.CreateJob(saved)
@@ -294,7 +353,11 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, deleteButton, layout.NewSpacer()) toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, deleteButton, layout.NewSpacer())
globalControls := container.NewHBox(stopAllButton, schedulerState, layout.NewSpacer()) globalControls := container.NewHBox(stopAllButton, schedulerState, layout.NewSpacer())
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), folderSelect, toolbar) // The view toggle sits beside the folder filter: the border layout gives it
// its MinSize on the right and lets the select fill the rest, so the header
// gains no height.
filterRow := container.NewBorder(nil, nil, nil, viewButton, folderSelect)
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), filterRow, toolbar)
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, list) sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, list)
fixedSidebar := container.New(minWidthLayout{width: minJobsSidebarWidth}, sidebar) fixedSidebar := container.New(minWidthLayout{width: minJobsSidebarWidth}, sidebar)
+25 -1
View File
@@ -1,6 +1,10 @@
package ui package ui
import "strings" import (
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
// lastJobLogs returns a fresh slice of the most recent activity entries for the // lastJobLogs returns a fresh slice of the most recent activity entries for the
// "Selected job activity" panel. Logs are stored newest-first (see // "Selected job activity" panel. Logs are stored newest-first (see
@@ -47,6 +51,26 @@ func filterValue(folder string) string {
return strings.TrimSpace(folder) return strings.TrimSpace(folder)
} }
// nextJobListView returns the mode the view toggle switches to. Anything that
// is not compact reads as detailed, so unknown and legacy values flip to
// compact just as an explicit "detailed" does.
func nextJobListView(current domain.JobListView) domain.JobListView {
if current.IsCompact() {
return domain.JobListViewDetailed
}
return domain.JobListViewCompact
}
// viewToggleText labels the view toggle with the action it performs, not the
// current state — the same convention as the "Disable auto"/"Enable auto"
// button. It also keeps the on-disk strings away from the user.
func viewToggleText(current domain.JobListView) string {
if current.IsCompact() {
return "Detailed"
}
return "Compact"
}
func indexOfID(jobs []job, id int) int { func indexOfID(jobs []job, id int) int {
for index, current := range jobs { for index, current := range jobs {
if current.ID == id { if current.ID == id {
+181
View File
@@ -3,7 +3,12 @@ package ui
import ( import (
"testing" "testing"
"gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain" "gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/test"
"fyne.io/fyne/v2/widget"
) )
func TestFilterValue(t *testing.T) { func TestFilterValue(t *testing.T) {
@@ -93,3 +98,179 @@ func TestFilteredJobIndexesEmptySlice(t *testing.T) {
t.Errorf("empty job list should return empty indexes, got %v", got) t.Errorf("empty job list should return empty indexes, got %v", got)
} }
} }
func TestNextJobListViewFlipsBothWays(t *testing.T) {
cases := []struct {
current, want domain.JobListView
}{
{domain.JobListViewDetailed, domain.JobListViewCompact},
{domain.JobListViewCompact, domain.JobListViewDetailed},
// Empty and unknown values read as detailed, so they flip to compact.
{"", domain.JobListViewCompact},
{"tiny", domain.JobListViewCompact},
}
for _, tc := range cases {
if got := nextJobListView(tc.current); got != tc.want {
t.Errorf("nextJobListView(%q) = %q, want %q", tc.current, got, tc.want)
}
}
}
// findFirst walks a widget tree depth-first and returns the first object the
// match function accepts. Tests use it to reach widgets newJobsView builds
// internally rather than returning.
func findFirst(root fyne.CanvasObject, match func(fyne.CanvasObject) bool) fyne.CanvasObject {
if match(root) {
return root
}
container, ok := root.(*fyne.Container)
if !ok {
return nil
}
for _, child := range container.Objects {
if found := findFirst(child, match); found != nil {
return found
}
}
return nil
}
// jobsSidebar narrows the search to the left pane. The details panel has a
// widget.List of its own (the activity log), so a search from the whole view
// would find the wrong one.
func jobsSidebar(t *testing.T, content fyne.CanvasObject) fyne.CanvasObject {
t.Helper()
found := findFirst(content, func(o fyne.CanvasObject) bool {
wrapper, ok := o.(*fyne.Container)
if !ok {
return false
}
_, ok = wrapper.Layout.(minWidthLayout)
return ok
})
if found == nil {
t.Fatal("jobs view has no fixed-width sidebar")
}
return found
}
func jobsList(t *testing.T, content fyne.CanvasObject) *widget.List {
t.Helper()
found := findFirst(jobsSidebar(t, content), func(o fyne.CanvasObject) bool {
_, ok := o.(*widget.List)
return ok
})
if found == nil {
t.Fatal("jobs sidebar contains no list widget")
}
return found.(*widget.List)
}
func jobsViewToggle(t *testing.T, content fyne.CanvasObject) *widget.Button {
t.Helper()
found := findFirst(jobsSidebar(t, content), func(o fyne.CanvasObject) bool {
button, ok := o.(*widget.Button)
return ok && (button.Text == "Compact" || button.Text == "Detailed")
})
if found == nil {
t.Fatal("jobs sidebar has no view toggle button")
}
return found.(*widget.Button)
}
// TestJobListViewToggleShrinksRowsAndPersists is the end-to-end guard for the
// compact view: one tap must shrink the list rows, relabel the button with the
// opposite action, and reach the config — and toggling back must undo all
// three. Row height is measured through List.CreateItem/UpdateItem because
// that is exactly what widget.List caches as the row height.
func TestJobListViewToggleShrinksRowsAndPersists(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
w := testApp.NewWindow("test")
defer w.Close()
store := newTestStore(t)
jobs := []domain.Job{
{ID: 1, Name: "Nightly backup", Folder: "Maintenance", Schedule: "@every 1m", Command: "echo hi", Enabled: true},
}
svc := app.NewService(store, jobs)
defer svc.Stop()
content, _ := newJobsView(w, svc)
w.SetContent(content)
list := jobsList(t, content)
viewButton := jobsViewToggle(t, content)
if viewButton.Text != "Compact" {
t.Fatalf("a default config should open detailed: button text = %q, want %q", viewButton.Text, "Compact")
}
rowHeight := func() float32 {
t.Helper()
row := list.CreateItem()
list.UpdateItem(0, row)
return row.MinSize().Height
}
detailedHeight := rowHeight()
test.Tap(viewButton)
if store.Config.JobListView != domain.JobListViewCompact {
t.Errorf("after tapping, Config.JobListView = %q, want %q", store.Config.JobListView, domain.JobListViewCompact)
}
if viewButton.Text != "Detailed" {
t.Errorf("after tapping, button text = %q, want %q", viewButton.Text, "Detailed")
}
compactHeight := rowHeight()
if compactHeight >= detailedHeight {
t.Errorf("compact row height = %v, want less than detailed %v", compactHeight, detailedHeight)
}
test.Tap(viewButton)
if store.Config.JobListView != domain.JobListViewDetailed {
t.Errorf("after tapping back, Config.JobListView = %q, want %q", store.Config.JobListView, domain.JobListViewDetailed)
}
if viewButton.Text != "Compact" {
t.Errorf("after tapping back, button text = %q, want %q", viewButton.Text, "Compact")
}
if got := rowHeight(); got != detailedHeight {
t.Errorf("row height after switching back = %v, want the original %v", got, detailedHeight)
}
}
// TestJobListViewCompactConfigOpensCompact checks the persisted preference is
// honoured at build time, not just after a tap.
func TestJobListViewCompactConfigOpensCompact(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
w := testApp.NewWindow("test")
defer w.Close()
store := newTestStore(t)
store.Config.JobListView = domain.JobListViewCompact
svc := app.NewService(store, nil)
defer svc.Stop()
content, _ := newJobsView(w, svc)
w.SetContent(content)
if got := jobsViewToggle(t, content).Text; got != "Detailed" {
t.Errorf("button text for a compact config = %q, want %q", got, "Detailed")
}
}
func TestViewToggleTextNamesTheAction(t *testing.T) {
cases := []struct {
current domain.JobListView
want string
}{
{domain.JobListViewDetailed, "Compact"},
{domain.JobListViewCompact, "Detailed"},
{"", "Compact"},
{"tiny", "Compact"},
}
for _, tc := range cases {
if got := viewToggleText(tc.current); got != tc.want {
t.Errorf("viewToggleText(%q) = %q, want %q", tc.current, got, tc.want)
}
}
}
+10 -3
View File
@@ -11,10 +11,13 @@ import (
"fyne.io/fyne/v2/test" "fyne.io/fyne/v2/test"
) )
func newTestService(t *testing.T) *app.Service { // newTestStore builds a Store rooted in a temp directory. It is separate from
// newTestService so tests that need a non-default Config (or their own jobs)
// can adjust it before handing it to app.NewService.
func newTestStore(t *testing.T) *storage.Store {
t.Helper() t.Helper()
dir := t.TempDir() dir := t.TempDir()
store := &storage.Store{ return &storage.Store{
Paths: storage.Paths{ Paths: storage.Paths{
ExecutablePath: filepath.Join(dir, "gosentry"), ExecutablePath: filepath.Join(dir, "gosentry"),
AppDir: dir, AppDir: dir,
@@ -35,7 +38,11 @@ func newTestService(t *testing.T) *app.Service {
NotifyOnFailure: true, NotifyOnFailure: true,
}, },
} }
return app.NewService(store, nil) }
func newTestService(t *testing.T) *app.Service {
t.Helper()
return app.NewService(newTestStore(t), nil)
} }
func TestMainViewBuilds(t *testing.T) { func TestMainViewBuilds(t *testing.T) {