From 5e3965ad38e39b3673ce450c5a4889f407fdfd37 Mon Sep 17 00:00:00 2001 From: James Devenish Date: Sun, 27 Oct 2024 21:35:45 +1100 Subject: [PATCH 1/7] Minor code style simplification --- config.go | 2 +- config_env.go | 2 +- logger_test.go | 24 ++++++++++++++---------- main.go | 2 +- main_test.go | 9 +++------ 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/config.go b/config.go index d6d752f..94f8a05 100644 --- a/config.go +++ b/config.go @@ -228,7 +228,7 @@ func LoadConfig(tomlFile string, tzConfigs []string) (*Config, error) { var keysDuplicated []string for _, keys := range allKeymaps { for _, key := range keys { - if _, used := keysUsed[key]; used == false { + if _, used := keysUsed[key]; !used { keysUsed[key] = true } else { keysDuplicated = append(keysDuplicated, key) diff --git a/config_env.go b/config_env.go index c26b6d8..3873184 100644 --- a/config_env.go +++ b/config_env.go @@ -55,7 +55,7 @@ func LoadConfigEnv(tzConfigs []string, now time.Time) (*Config, error) { // ReadZoneFromString from current time and a zoneConf string func ReadZoneFromString(now time.Time, zoneConf string) (*Zone, error) { names := strings.Split(zoneConf, ",") - dbName := strings.Trim(names[0], " ") + dbName := strings.TrimSpace(names[0]) var name string if len(names) == 2 { name = names[1] diff --git a/logger_test.go b/logger_test.go index 5651d6d..17653b0 100644 --- a/logger_test.go +++ b/logger_test.go @@ -26,6 +26,13 @@ import ( func TestLogger(t *testing.T) { oldDebug := os.Getenv("DEBUG") + t.Cleanup(func () { + if oldDebug != "1" { + os.Setenv("DEBUG", oldDebug) + SetupLogger() + } + }) + os.Setenv("DEBUG", "1") SetupLogger() @@ -41,15 +48,17 @@ func TestLogger(t *testing.T) { if !strings.Contains(lastLine, logMsg) { t.Errorf("Missing log line in debug.log: %s", logMsg) } - - if oldDebug != "1" { - os.Setenv("DEBUG", oldDebug) - SetupLogger() - } } func TestNoLogger(t *testing.T) { oldDebug, debugWasSet := os.LookupEnv("DEBUG") + t.Cleanup(func () { + if debugWasSet { + os.Setenv("DEBUG", oldDebug) + SetupLogger() + } + }) + os.Unsetenv("DEBUG") SetupLogger() @@ -63,9 +72,4 @@ func TestNoLogger(t *testing.T) { if strings.Contains(string(out), logMsg) { t.Errorf("Log file debug.log contained forbidden string: %s", logMsg) } - - if debugWasSet { - os.Setenv("DEBUG", oldDebug) - SetupLogger() - } } diff --git a/main.go b/main.go index f58b963..c55a129 100644 --- a/main.go +++ b/main.go @@ -200,7 +200,7 @@ func main() { watch := flag.Bool("w", false, "watch live, set time to now every minute") flag.Parse() - if *showVersion == true { + if *showVersion { fmt.Printf("tz %s\n", CurrentVersion) os.Exit(0) } diff --git a/main_test.go b/main_test.go index 344f1b9..0cba324 100644 --- a/main_test.go +++ b/main_test.go @@ -109,8 +109,7 @@ func TestUpdateIncHour(t *testing.T) { da := ta.Day() if cmd != nil { - t.Errorf("Expected nil Cmd, but got %v", cmd) - return + t.Fatalf("Expected nil Cmd, but got %v", cmd) } h := m.clock.t.Hour() if h != test.nextHour { @@ -151,8 +150,7 @@ func TestUpdateDecHour(t *testing.T) { } _, cmd := m.Update(msg) if cmd != nil { - t.Errorf("Expected nil Cmd, but got %v", cmd) - return + t.Fatalf("Expected nil Cmd, but got %v", cmd) } h := m.clock.t.Hour() if h != test.nextHour { @@ -223,8 +221,7 @@ func TestUpdateQuitMsg(t *testing.T) { } _, cmd := m.Update(msg) if cmd == nil { - t.Errorf("Expected tea.Quit Cmd, but got %v", cmd) - return + t.Fatalf("Expected tea.Quit Cmd, but got %v", cmd) } // tea.Quit is a function, we can't really test with == here, and // calling it is getting into internal territory. From 1277b2126dffed7a7d3d06e72b2366946ff48bab Mon Sep 17 00:00:00 2001 From: James Devenish Date: Sun, 27 Oct 2024 21:35:46 +1100 Subject: [PATCH 2/7] Add os_wrapper.go --- config_env.go | 1 - config_file.go | 1 - config_test.go | 1 - logger.go | 1 - logger_test.go | 1 - main.go | 15 +++++---- main_test.go | 1 - os_wrapper.go | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++ view.go | 3 +- view_test.go | 1 - 10 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 os_wrapper.go diff --git a/config_env.go b/config_env.go index 3873184..73beb95 100644 --- a/config_env.go +++ b/config_env.go @@ -18,7 +18,6 @@ package main import ( "fmt" - "os" "strings" "time" ) diff --git a/config_file.go b/config_file.go index b45cd98..855a628 100644 --- a/config_file.go +++ b/config_file.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "os" "path/filepath" "time" diff --git a/config_test.go b/config_test.go index 34c0920..727e90f 100644 --- a/config_test.go +++ b/config_test.go @@ -17,7 +17,6 @@ package main import ( - "os" "strings" "testing" "time" diff --git a/logger.go b/logger.go index 729d031..a9147c4 100644 --- a/logger.go +++ b/logger.go @@ -2,7 +2,6 @@ package main import ( "log" - "os" tea "github.com/charmbracelet/bubbletea" ) diff --git a/logger_test.go b/logger_test.go index 17653b0..536c3f8 100644 --- a/logger_test.go +++ b/logger_test.go @@ -18,7 +18,6 @@ package main import ( "fmt" - "os" "math/rand" "strings" "testing" diff --git a/main.go b/main.go index c55a129..0e08eca 100644 --- a/main.go +++ b/main.go @@ -19,7 +19,6 @@ package main import ( "flag" "fmt" - "os" "os/exec" "runtime" "slices" @@ -201,8 +200,9 @@ func main() { flag.Parse() if *showVersion { - fmt.Printf("tz %s\n", CurrentVersion) + fmt.Fprintf(os.Stdout(), "tz %s\n", CurrentVersion) os.Exit(0) + return } if *doSearch { @@ -211,14 +211,16 @@ func main() { q = arg } results := SearchZones(strings.ToLower(q)) - results.Print(os.Stdout) + results.Print(os.Stdout()) os.Exit(0) + return } config, err := LoadDefaultConfig(flag.Args()) if err != nil { - fmt.Fprintf(os.Stderr, "Config error: %s\n", err) + fmt.Fprintf(os.Stderr(), "Config error: %s\n", err) os.Exit(2) + return } var initialModel = model{ @@ -236,11 +238,12 @@ func main() { initialModel.clock = *NewClockUnixTimestamp(*when) } - initialModel.interactive = !*exitQuick && isatty.IsTerminal(os.Stdout.Fd()) + initialModel.interactive = !*exitQuick && isatty.IsTerminal(os.Stdout().Fd()) p := tea.NewProgram(&initialModel) if err := p.Start(); err != nil { - fmt.Printf("Alas, there's been an error: %v", err) + fmt.Fprintf(os.Stderr(), "Alas, there's been an error: %v", err) os.Exit(1) + return } } diff --git a/main_test.go b/main_test.go index 0cba324..43ad1e3 100644 --- a/main_test.go +++ b/main_test.go @@ -17,7 +17,6 @@ package main import ( - "os" "regexp" "strings" "testing" diff --git a/os_wrapper.go b/os_wrapper.go new file mode 100644 index 0000000..9c60dda --- /dev/null +++ b/os_wrapper.go @@ -0,0 +1,83 @@ +/** + * This file is part of tz. + * + * tz is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * tz is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public + * License for more details. + * + * You should have received a copy of the GNU General Public License + * along with tz. If not, see . + **/ +package main + +import ( + platform "os" +) + +var os OsWrapper = PlatformOsWrapper{} + +type OsWrapper interface { + Exit(code int) + Getenv(key string) string + LookupEnv(key string) (string, bool) + ReadFile(name string) ([]byte, error) + Setenv(key, value string) error + Stderr() *platform.File + Stdin() *platform.File + Stdout() *platform.File + Unsetenv(key string) error + UserHomeDir() (string, error) + WriteFile(name string, data []byte, perm platform.FileMode) error +} + +type PlatformOsWrapper struct {} + +func (_ PlatformOsWrapper) Exit(code int) { + platform.Exit(code) +} + +func (_ PlatformOsWrapper) Getenv(key string) string { + return platform.Getenv(key) +} + +func (_ PlatformOsWrapper) LookupEnv(key string) (string, bool) { + return platform.LookupEnv(key) +} + +func (_ PlatformOsWrapper) ReadFile(name string) ([]byte, error) { + return platform.ReadFile(name) +} + +func (_ PlatformOsWrapper) Setenv(key, value string) error { + return platform.Setenv(key, value) +} + +func (_ PlatformOsWrapper) Stderr() *platform.File { + return platform.Stderr +} + +func (_ PlatformOsWrapper) Stdin() *platform.File { + return platform.Stdin +} + +func (_ PlatformOsWrapper) Stdout() *platform.File { + return platform.Stdout +} + +func (_ PlatformOsWrapper) Unsetenv(key string) error { + return platform.Unsetenv(key) +} + +func (_ PlatformOsWrapper) UserHomeDir() (string, error) { + return platform.UserHomeDir() +} + +func (_ PlatformOsWrapper) WriteFile(name string, data []byte, perm platform.FileMode) error { + return platform.WriteFile(name, data, perm) +} diff --git a/view.go b/view.go index 9625659..87a84b3 100644 --- a/view.go +++ b/view.go @@ -18,7 +18,6 @@ package main import ( "fmt" - "os" "strconv" "strings" "time" @@ -100,7 +99,7 @@ func (m model) View() string { if envErr == nil { zoneHeaderWidth = min(envWidth, zoneHeaderWidth) } else { - fd := int(os.Stdout.Fd()) + fd := int(os.Stdout().Fd()) if xterm.IsTerminal(fd) { termWidth, _, termErr := xterm.GetSize(fd) if termErr == nil { diff --git a/view_test.go b/view_test.go index 28b991c..f27dd50 100644 --- a/view_test.go +++ b/view_test.go @@ -18,7 +18,6 @@ package main import ( "fmt" - "os" "strings" "testing" "time" From 2d685b422fdfc2056d62a4785d497ec94ce33911 Mon Sep 17 00:00:00 2001 From: James Devenish Date: Sun, 27 Oct 2024 21:45:17 +1100 Subject: [PATCH 3/7] Add os_wrapper_test.go --- config_test.go | 9 +-- os_wrapper_test.go | 172 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 os_wrapper_test.go diff --git a/config_test.go b/config_test.go index 727e90f..a713bd0 100644 --- a/config_test.go +++ b/config_test.go @@ -36,19 +36,12 @@ func TestConfigKeysDuplicated(t *testing.T) { } func TestLoadConfig(t *testing.T) { - oldTzList, tzListWasSet := os.LookupEnv("TZ_LIST") - os.Unsetenv("TZ_LIST") - + _ = NewTestingOsWrapper(t) tomlPath := "./example-conf.toml" _, err := LoadConfig(tomlPath, nil) if err != nil { t.Errorf("Could not read %s: %v", tomlPath, err) } - - if tzListWasSet { - os.Setenv("TZ_LIST", oldTzList) - SetupLogger() - } } func TestLoadConfigParser(t *testing.T) { diff --git a/os_wrapper_test.go b/os_wrapper_test.go new file mode 100644 index 0000000..d0cb33d --- /dev/null +++ b/os_wrapper_test.go @@ -0,0 +1,172 @@ +/** + * This file is part of tz. + * + * tz is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * tz is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public + * License for more details. + * + * You should have received a copy of the GNU General Public License + * along with tz. If not, see . + **/ +package main + +import ( + "errors" + platform "os" + "testing" +) + +type TestingOsWrapper struct { + EnvVars map[string]string + ExitCode *int + Files map[string][]byte + HomeDir string + PlatformStdinFile *platform.File + PlatformStderrFile *platform.File + PlatformStdoutFile *platform.File + TestStdinFile *platform.File + TestStderrFile *platform.File + TestStdoutFile *platform.File +} + +func NewTestingOsWrapper(t *testing.T) *TestingOsWrapper { + savedOs := os + savedOsArgs := platform.Args + t.Cleanup(func () { + os = savedOs + platform.Args = savedOsArgs + }) + + stderr, err1 := platform.CreateTemp("", "stderr") + if err1 != nil { + t.Fatalf("Could not create stderr temp file for %v: %v", t.Name(), err1) + } + t.Cleanup(func () { + stderr.Sync() + file := stderr.Name() + bytes, _ := platform.ReadFile(file) + platform.Stderr.Write(bytes) + platform.Remove(file) + }) + + stdout, err2 := platform.CreateTemp("", "stdout") + if err2 != nil { + t.Fatalf("Could not create stdout temp file for %v: %v", t.Name(), err2) + } + t.Cleanup(func () { + stdout.Sync() + file := stdout.Name() + bytes, _ := platform.ReadFile(file) + platform.Stdout.Write(bytes) + platform.Remove(file) + }) + + osWrapper := &TestingOsWrapper{ + EnvVars: make(map[string]string), + Files: make(map[string][]byte), + TestStderrFile: stderr, + TestStdinFile: platform.Stdin, + TestStdoutFile: stdout, + PlatformStderrFile: platform.Stderr, + PlatformStdinFile: platform.Stdin, + PlatformStdoutFile: platform.Stdout, + } + os = osWrapper + return osWrapper +} + +func (t *TestingOsWrapper) ConsumeStderr() string { + t.TestStderrFile.Sync() + file := t.TestStderrFile.Name() + bytes, _ := platform.ReadFile(file) + platform.Truncate(file, 0) + return string(bytes) +} + +func (t *TestingOsWrapper) ConsumeStdout() string { + t.TestStdoutFile.Sync() + file := t.TestStdoutFile.Name() + bytes, _ := platform.ReadFile(file) + platform.Truncate(file, 0) + return string(bytes) +} + +func (t *TestingOsWrapper) Exit(code int) { + t.ExitCode = &code +} + +func (t *TestingOsWrapper) Getenv(key string) string { + return t.EnvVars[key] +} + +func (t *TestingOsWrapper) LookupEnv(key string) (string, bool) { + value, exists := t.EnvVars[key] + return value, exists +} + +func (t *TestingOsWrapper) ReadFile(name string) ([]byte, error) { + if bytes, exists := t.Files[name]; exists { + return bytes, nil + } else { + return platform.ReadFile(name) + } +} + +func (t *TestingOsWrapper) RedirectPlatformOutput() { + platform.Stderr = t.TestStderrFile + platform.Stdout = t.TestStdoutFile +} + +func (t *TestingOsWrapper) RevertPlatformOutput() { + platform.Stderr = t.PlatformStderrFile + platform.Stdout = t.PlatformStdoutFile +} + +func (t *TestingOsWrapper) Setargs(args []string) { + platform.Args = append([]string{platform.Args[0]}, args...) +} + +func (t *TestingOsWrapper) Setenv(key, value string) error { + t.EnvVars[key] = value + return nil +} + +func (t *TestingOsWrapper) Stderr() *platform.File { + return t.TestStderrFile +} + +func (t *TestingOsWrapper) Stdin() *platform.File { + return t.TestStdinFile +} + +func (t *TestingOsWrapper) Stdout() *platform.File { + return t.TestStdoutFile +} + +func (t *TestingOsWrapper) Unsetenv(key string) error { + delete(t.EnvVars, key) + return nil +} + +func (t *TestingOsWrapper) UserHomeDir() (string, error) { + if len(t.HomeDir) < 1 { + return t.HomeDir, errors.New("TestingOsWrapper.HomeDir is not yet set") + } else { + return t.HomeDir, nil + } +} + +func (t *TestingOsWrapper) WriteFile(name string, data []byte, perm platform.FileMode) error { + if _, exists := t.Files[name]; exists { + t.Files[name] = data + return nil + } else { + return platform.WriteFile(name, data, perm) + } +} From d68ae1cef0e8c3bdce1acf767f368b113c34035d Mon Sep 17 00:00:00 2001 From: James Devenish Date: Sun, 27 Oct 2024 21:46:09 +1100 Subject: [PATCH 4/7] main_test.go: More coverage of main.go (fix #72) --- main.go | 17 ++-- main_test.go | 257 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 6 deletions(-) diff --git a/main.go b/main.go index 0e08eca..008aee8 100644 --- a/main.go +++ b/main.go @@ -187,8 +187,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -func main() { - SetupLogger() +func parseMainArgs() *model { logger.Println("Startup") exitQuick := flag.Bool("q", false, "exit immediately") @@ -202,7 +201,7 @@ func main() { if *showVersion { fmt.Fprintf(os.Stdout(), "tz %s\n", CurrentVersion) os.Exit(0) - return + return nil } if *doSearch { @@ -213,14 +212,14 @@ func main() { results := SearchZones(strings.ToLower(q)) results.Print(os.Stdout()) os.Exit(0) - return + return nil } config, err := LoadDefaultConfig(flag.Args()) if err != nil { fmt.Fprintf(os.Stderr(), "Config error: %s\n", err) os.Exit(2) - return + return nil } var initialModel = model{ @@ -240,7 +239,13 @@ func main() { initialModel.interactive = !*exitQuick && isatty.IsTerminal(os.Stdout().Fd()) - p := tea.NewProgram(&initialModel) + return &initialModel +} + +func main() { + SetupLogger() + initialModel := parseMainArgs() + p := tea.NewProgram(initialModel) if err := p.Start(); err != nil { fmt.Fprintf(os.Stderr(), "Alas, there's been an error: %v", err) os.Exit(1) diff --git a/main_test.go b/main_test.go index 43ad1e3..aa8b3fd 100644 --- a/main_test.go +++ b/main_test.go @@ -17,6 +17,8 @@ package main import ( + "flag" + "fmt" "regexp" "strings" "testing" @@ -48,6 +50,17 @@ var ( } ) +func failUnlessExpectedError(t *testing.T, err error, expectedError string, contextFormat string, a ...any) { + msg := fmt.Sprintf(contextFormat, a...) + if err != nil { + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected specific error %s, but got a different error: %v", msg, err) + } + } else { + t.Fatalf("Expected error %s, but none occurred", msg) + } +} + func getTimestampWithHour(hour int) time.Time { return time.Date( time.Now().Year(), @@ -61,6 +74,21 @@ func getTimestampWithHour(hour int) time.Time { ) } +func parseMainArgsWithPanicRecovery(panicErr *error) *model { + *panicErr = nil + savedFlags := flag.CommandLine + flag.CommandLine = flag.NewFlagSet("main_test", flag.PanicOnError) + + defer func() { + flag.CommandLine = savedFlags + if err, recovered := recover().(error); recovered { + *panicErr = err + } + }() + + return parseMainArgs() +} + func stripAnsiControlSequences(s string) string { return ansiControlSequenceRegexp.ReplaceAllString(s, "") } @@ -70,11 +98,216 @@ func stripAnsiControlSequencesAndNewline(bytes []byte) string { return ansiControlSequenceRegexp.ReplaceAllString(s, "") } +func testMainArgWhen(t *testing.T, when int64) { + var err error + var osWrapper = NewTestingOsWrapper(t) + + // 1. Test initial state with -when (overrides -w) + osWrapper.HomeDir = "." + osWrapper.Setargs([]string{"-when", fmt.Sprintf("%v", when), "-w"}) + model := parseMainArgsWithPanicRecovery(&err) + + if err != nil { + t.Errorf("Unexpected failure for `-when %v`: %v", when, err) + } + if osWrapper.ExitCode != nil { + t.Errorf("Unexpected exit code for -when %v`: %v", when, *osWrapper.ExitCode) + } + if model == nil { + t.Fatalf("Model was nil after parsing `-when %v`", when) + } + if model.clock.t.Unix() != when { + t.Errorf("Model `-when %v` clock.time was incorrect: %v (%v)", when, model.clock.t.Unix(), model.clock.t) + } + if model.clock.isRealTime { + t.Errorf("Model `-when %v` clock.isRealTime was incorrect: %v", when, model.clock.isRealTime) + } + + // 2. Test that tick does not change the state with -when + tickMsg := tickMsg(time.Now()) + if _, cmd := model.Update(tickMsg); cmd == nil { + t.Fatalf("Expected non-nil Cmd, but got %v", cmd) + } + if model.clock.t.Unix() != when { + t.Errorf("Model `-when %v` clock.time was unstable after tickMsg: %v (%v)", when, model.clock.t.Unix(), model.clock.t) + } + if model.clock.isRealTime { + t.Errorf("Model `-when %v` clock.isRealTime was incorrect after tickMsg: %v", when, model.clock.isRealTime) + } + + // 3. Cancel `-when` and activate `-w` using the interactive `t` key + keyMsg := tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune{'t'}, + } + if _, cmd := model.Update(keyMsg); cmd != nil { + t.Fatalf("Expected nil Cmd, but got %v", cmd) + } + if model.clock.t.Unix() == when { + t.Errorf("Model clock.time was incorrect after `t` key: %v (time %v)", model.clock.t.Unix(), model.clock.t) + } + if !model.clock.isRealTime { + t.Errorf("Model clock.isRealTime was incorrect after `t` key: %v", model.clock.isRealTime) + } + + // 4. Test that ticks are tracking the current time with -w + oldTime := model.clock.t + if _, cmd := model.Update(tickMsg); cmd == nil { + t.Fatalf("Expected non-nil Cmd, but got %v", cmd) + } + if model.clock.t == oldTime { + t.Errorf("Model clock.time was incorrect with -w option: %v", model.clock.t) + } + if !model.clock.isRealTime { + t.Errorf("Model clock.isRealTime was incorrect with -w option: %v", model.clock.isRealTime) + } +} + func TestMain(m *testing.M) { SetupLogger() m.Run() } +func TestMainArgNone(t *testing.T) { + var err error + var osWrapper = NewTestingOsWrapper(t) + + osWrapper.HomeDir = "." + osWrapper.Setargs([]string{}) + model := parseMainArgsWithPanicRecovery(&err) + + if err != nil { + t.Errorf("Unexpected failure for empty flag args: %v", err) + } + if osWrapper.ExitCode != nil { + t.Errorf("Unexpected exit code for zero flag args: %v", *osWrapper.ExitCode) + } + if model == nil { + t.Errorf("Model was nil after parsing flag args") + } else { + if model.isMilitary { + t.Errorf("Default model.isMilitary was %v but expected false", model.isMilitary) + } + if model.showDates { + t.Errorf("Default model.showDates was %v but expected false", model.showDates) + } + if model.showHelp { + t.Errorf("Default model.showHelp was %v but expected false", model.showHelp) + } + if model.watch { + t.Errorf("Default model.watch was %v but expected false", model.watch) + } + if !model.clock.isRealTime { + t.Errorf("Default model.clock.isRealTime was %v but expected true", model.clock.isRealTime) + } + } + + osWrapper.HomeDir = "" + expectedOutput := "Config error: File error: TestingOsWrapper.HomeDir is not yet set" + parseMainArgsWithPanicRecovery(&err) + if osWrapper.ExitCode == nil { + t.Error("Main should exit for invalid HomeDir, but it did not") + } else if *osWrapper.ExitCode != 2 { + t.Errorf("Main should exit with code 0 for invalid HomeDir, but got %v", *osWrapper.ExitCode) + } + if output := strings.TrimSpace(osWrapper.ConsumeStderr()); output != expectedOutput { + t.Errorf("Main should have printed '%v', but got '%v'", expectedOutput, output) + } +} + +func TestMainArgInvalid(t *testing.T) { + var err error + var osWrapper = NewTestingOsWrapper(t) + + osWrapper.Setargs([]string{"- "}) + osWrapper.RedirectPlatformOutput() // capture "flag" package messages + parseMainArgsWithPanicRecovery(&err) + osWrapper.RevertPlatformOutput() // print "testing" package messages + failUnlessExpectedError(t, err, "flag provided but not defined: - ", "in %s", t.Name()) + + expectedOutputFragment := "flag provided but not defined: - \nUsage" + if output := strings.TrimSpace(osWrapper.ConsumeStderr()); !strings.Contains(output, expectedOutputFragment) { + t.Errorf("Main `- ` flag arg should have printed '%v', but got '%v'", expectedOutputFragment, output) + } +} + +func TestMainArgList(t *testing.T) { + var err error + var osWrapper = NewTestingOsWrapper(t) + + osWrapper.Setargs([]string{"-list", "UTC"}) + parseMainArgsWithPanicRecovery(&err) + if err != nil { + t.Errorf("Unexpected failure for -list flag: %v", err) + } + if osWrapper.ExitCode == nil { + t.Error("Main -list should exit, but it did not") + } else if *osWrapper.ExitCode != 0 { + t.Errorf("Main -list should exit with code 0, but got %v", *osWrapper.ExitCode) + } + + stderr := osWrapper.ConsumeStderr() + if len(stderr) > 0 { + t.Errorf("Unexpected stderr for -list flag: %v", stderr) + } + + stdout := osWrapper.ConsumeStdout() + expectedOutputFragment := "UTC (+00:00) :: UTC" + if !strings.Contains(stdout, expectedOutputFragment) { + t.Errorf("Stdout for -list flag did not contain '%v': %v", expectedOutputFragment, stdout) + } + + osWrapper.Setargs([]string{"-list", "!"}) + expectedOutput := "Unknown time zone !" + parseMainArgsWithPanicRecovery(&err) + if err != nil { + t.Errorf("Unexpected failure for -list flag: %v", err) + } + if osWrapper.ExitCode == nil { + t.Error("Main -list should exit, but did not") + } else if *osWrapper.ExitCode != 3 { + t.Errorf("Main -list should exit with code 3, but got %v", *osWrapper.ExitCode) + } + if output := strings.TrimSpace(osWrapper.ConsumeStderr()); output != expectedOutput { + t.Errorf("Main -list should have printed '%v', but got '%v'", expectedOutput, output) + } +} + +func TestMainArgVersion(t *testing.T) { + var err error + var osWrapper = NewTestingOsWrapper(t) + + osWrapper.Setargs([]string{"-v"}) + parseMainArgsWithPanicRecovery(&err) + + if err != nil { + t.Errorf("Unexpected failure for -v flag: %v", err) + } + + stderr := osWrapper.ConsumeStderr() + if len(stderr) > 0 { + t.Errorf("Unexpected stderr for -v flag: %v", stderr) + } + + stdout := strings.TrimSpace(osWrapper.ConsumeStdout()) + expectedOutput := fmt.Sprintf("tz %v", CurrentVersion) + if stdout != expectedOutput { + t.Errorf("Unexpected stdout for -v flag (expected '%v'): %v", expectedOutput, stdout) + } + + if osWrapper.ExitCode == nil { + t.Error("Main -v should exit, but did not") + } else if *osWrapper.ExitCode != 0 { + t.Errorf("Main -v should exit with code 0, but got %v", *osWrapper.ExitCode) + } +} + +func TestMainArgWhen(t *testing.T) { + testMainArgWhen(t, 1) + testMainArgWhen(t, -1) + testMainArgWhen(t, 0) +} + func TestUpdateIncHour(t *testing.T) { // "l" key -> go right msg := tea.KeyMsg{ @@ -158,6 +391,30 @@ func TestUpdateDecHour(t *testing.T) { } } +func TestUpdateShowDatesMsg(t *testing.T) { + // "d" key -> toggle dates + msg := tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune{'d'}, + } + + m := utcMinuteAfterMidnightModel + + dateMarker := "📆" + + if !strings.Contains(m.View(), dateMarker) { + t.Fatalf("Dates should be shown in utcMinuteAfterMidnightModel") + } + + if _, cmd := m.Update(msg); cmd != nil { + t.Fatalf("Expected nil Cmd, but got %v", cmd) + } + + if strings.Contains(m.View(), dateMarker) { + t.Fatalf("Dates should have been toggled after `d` key") + } +} + func TestUpdateShowHelpMsg(t *testing.T) { // "?" key -> help msg := tea.KeyMsg{ From 75be86741249c37a1002df49d664e69e8f550292 Mon Sep 17 00:00:00 2001 From: James Devenish Date: Sun, 27 Oct 2024 21:48:31 +1100 Subject: [PATCH 5/7] main.go: Support -when 0 and ISO 8601/RFC 3339 (fix #71) --- main.go | 30 ++++++++++++++++++++++++++++-- main_test.go | 34 ++++++++++++++++++++++++++-------- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/main.go b/main.go index 008aee8..4ac9d38 100644 --- a/main.go +++ b/main.go @@ -17,11 +17,13 @@ package main import ( + "errors" "flag" "fmt" "os/exec" "runtime" "slices" + "strconv" "strings" "time" @@ -38,6 +40,29 @@ var ( hasDarkBackground = termenv.HasDarkBackground() ) +type whenValue struct { + when **int64 +} + +func (w whenValue) String() string { + if w.when != nil && *w.when != nil { + return strconv.FormatInt(**w.when, 10) + } + return "" +} + +func (w whenValue) Set(s string) error { + if i, err := strconv.ParseInt(s, 0, 64); err == nil { + *w.when = &i + return nil + } else if t, err := time.Parse(time.RFC3339, s); err == nil { + u := t.Unix() + *w.when = &u + return nil + } + return errors.New("Could not parse integer (format: 123) or date-time (format: 2006-01-02T15:04:05+07:00)") +} + type tickMsg time.Time // Send a tickMsg every minute, on the minute. @@ -190,9 +215,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func parseMainArgs() *model { logger.Println("Startup") + var when *int64 + flag.Var(whenValue{&when}, "when", "date-time in seconds since unix epoch, or in ISO8601/RFC3339 format (disables -w)") exitQuick := flag.Bool("q", false, "exit immediately") showVersion := flag.Bool("v", false, "show version") - when := flag.Int64("when", 0, "time in seconds since unix epoch (disables -w)") doSearch := flag.Bool("list", false, "[filter] list or search zones by name") military := flag.Bool("m", false, "use 24-hour time") watch := flag.Bool("w", false, "watch live, set time to now every minute") @@ -233,7 +259,7 @@ func parseMainArgs() *model { zoneStyle: AbbreviationZoneStyle, } - if *when != 0 { + if when != nil { initialModel.clock = *NewClockUnixTimestamp(*when) } diff --git a/main_test.go b/main_test.go index aa8b3fd..5b858c0 100644 --- a/main_test.go +++ b/main_test.go @@ -98,13 +98,13 @@ func stripAnsiControlSequencesAndNewline(bytes []byte) string { return ansiControlSequenceRegexp.ReplaceAllString(s, "") } -func testMainArgWhen(t *testing.T, when int64) { +func testMainArgWhen(t *testing.T, when string, whenSeconds int64) { var err error var osWrapper = NewTestingOsWrapper(t) // 1. Test initial state with -when (overrides -w) osWrapper.HomeDir = "." - osWrapper.Setargs([]string{"-when", fmt.Sprintf("%v", when), "-w"}) + osWrapper.Setargs([]string{"-when", when, "-w"}) model := parseMainArgsWithPanicRecovery(&err) if err != nil { @@ -116,7 +116,7 @@ func testMainArgWhen(t *testing.T, when int64) { if model == nil { t.Fatalf("Model was nil after parsing `-when %v`", when) } - if model.clock.t.Unix() != when { + if model.clock.t.Unix() != whenSeconds { t.Errorf("Model `-when %v` clock.time was incorrect: %v (%v)", when, model.clock.t.Unix(), model.clock.t) } if model.clock.isRealTime { @@ -128,7 +128,7 @@ func testMainArgWhen(t *testing.T, when int64) { if _, cmd := model.Update(tickMsg); cmd == nil { t.Fatalf("Expected non-nil Cmd, but got %v", cmd) } - if model.clock.t.Unix() != when { + if model.clock.t.Unix() != whenSeconds { t.Errorf("Model `-when %v` clock.time was unstable after tickMsg: %v (%v)", when, model.clock.t.Unix(), model.clock.t) } if model.clock.isRealTime { @@ -143,7 +143,7 @@ func testMainArgWhen(t *testing.T, when int64) { if _, cmd := model.Update(keyMsg); cmd != nil { t.Fatalf("Expected nil Cmd, but got %v", cmd) } - if model.clock.t.Unix() == when { + if model.clock.t.Unix() == whenSeconds { t.Errorf("Model clock.time was incorrect after `t` key: %v (time %v)", model.clock.t.Unix(), model.clock.t) } if !model.clock.isRealTime { @@ -303,9 +303,27 @@ func TestMainArgVersion(t *testing.T) { } func TestMainArgWhen(t *testing.T) { - testMainArgWhen(t, 1) - testMainArgWhen(t, -1) - testMainArgWhen(t, 0) + testMainArgWhen(t, "1", 1) + testMainArgWhen(t, "-1", -1) + testMainArgWhen(t, "0", 0) + testMainArgWhen(t, "1970-01-01T00:00:01Z", 1) + testMainArgWhen(t, "2006-01-02T15:04:05-07:00", 1136239445) +} + +func TestMainArgWhenInvalid(t *testing.T) { + var err error + var osWrapper = NewTestingOsWrapper(t) + + osWrapper.Setargs([]string{"-when", "midnight"}) + osWrapper.RedirectPlatformOutput() // capture "flag" package messages + parseMainArgsWithPanicRecovery(&err) + osWrapper.RevertPlatformOutput() // print "testing" package messages + failUnlessExpectedError(t, err, "invalid value \"midnight\" for flag -when: Could not parse", "in %s", t.Name()) + + expectedUsageFragment := "date-time in seconds since unix epoch, or in ISO8601/RFC3339 format" + if output := strings.TrimSpace(osWrapper.ConsumeStderr()); !strings.Contains(output, expectedUsageFragment) { + t.Errorf("Main `-when midnight` flag arg should have printed '%v', but got '%v'", expectedUsageFragment, output) + } } func TestUpdateIncHour(t *testing.T) { From 56806ee90554c8f528e77a7e5133504c740bc094 Mon Sep 17 00:00:00 2001 From: James Devenish Date: Sun, 27 Oct 2024 21:49:32 +1100 Subject: [PATCH 6/7] main.go: Non-zero -list exit (fix #70) --- main.go | 5 +++++ main_test.go | 15 +++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/main.go b/main.go index 4ac9d38..a75c5cf 100644 --- a/main.go +++ b/main.go @@ -236,6 +236,11 @@ func parseMainArgs() *model { q = arg } results := SearchZones(strings.ToLower(q)) + if len(results) < 1 { + fmt.Fprintf(os.Stderr(), "Unknown time zone %s\n", q) + os.Exit(3) + return nil + } results.Print(os.Stdout()) os.Exit(0) return nil diff --git a/main_test.go b/main_test.go index 5b858c0..b3df2df 100644 --- a/main_test.go +++ b/main_test.go @@ -258,18 +258,21 @@ func TestMainArgList(t *testing.T) { } osWrapper.Setargs([]string{"-list", "!"}) - expectedOutput := "Unknown time zone !" + expectedErrOutput := "Unknown time zone !" parseMainArgsWithPanicRecovery(&err) if err != nil { - t.Errorf("Unexpected failure for -list flag: %v", err) + t.Errorf("Unexpected failure for `-list !` flag: %v", err) } if osWrapper.ExitCode == nil { - t.Error("Main -list should exit, but did not") + t.Error("Main `-list !` should exit, but did not") } else if *osWrapper.ExitCode != 3 { - t.Errorf("Main -list should exit with code 3, but got %v", *osWrapper.ExitCode) + t.Errorf("Main `-list !` should exit with code 3, but got %v", *osWrapper.ExitCode) } - if output := strings.TrimSpace(osWrapper.ConsumeStderr()); output != expectedOutput { - t.Errorf("Main -list should have printed '%v', but got '%v'", expectedOutput, output) + if errOutput := strings.TrimSpace(osWrapper.ConsumeStderr()); errOutput != expectedErrOutput { + t.Errorf("Main `-list !` should have stderr '%v', but got '%v'", expectedErrOutput, errOutput) + } + if stdOutput := osWrapper.ConsumeStdout(); len(stdOutput) > 0 { + t.Errorf("Main `-list !` should have empty stdout, but got '%v'", stdOutput) } } From 6f4d81ee55c9661b8b90074dcd466fda735b9a05 Mon Sep 17 00:00:00 2001 From: James Devenish Date: Sun, 27 Oct 2024 21:50:09 +1100 Subject: [PATCH 7/7] config_file_test.go: More coverage of config_file.go --- config_file_test.go | 31 ++++++++++++++++++++++++++ example-conf.toml | 3 +-- testdata/config_file/empty.toml | 0 testdata/config_file/invalid.toml | 1 + testdata/config_file/unknown_zone.toml | 2 ++ 5 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 testdata/config_file/empty.toml create mode 100644 testdata/config_file/invalid.toml create mode 100644 testdata/config_file/unknown_zone.toml diff --git a/config_file_test.go b/config_file_test.go index caf3aaa..382d86d 100644 --- a/config_file_test.go +++ b/config_file_test.go @@ -28,6 +28,15 @@ func TestDefaultConfigFile(t *testing.T) { } } +func TestEmptyConfigFile(t *testing.T) { + now := time.Now() + tomlPath := "./testdata/config_file/empty.toml" + _, err := LoadConfigFile(tomlPath, now) + if err != nil { + t.Fatalf("Unexpected error reading empty config %s: %v", tomlPath, err) + } +} + func TestExampleConfigFile(t *testing.T) { now := time.Now() tomlPath := "./example-conf.toml" @@ -44,3 +53,25 @@ func TestExampleConfigFile(t *testing.T) { t.Errorf("Expected at least 2 keys for open_web in %s, found %v", tomlPath, len(config.Keymaps.OpenWeb)) } } + +func TestInvalidConfigFile(t *testing.T) { + now := time.Now() + tomlPath := "./testdata/config_file/invalid.toml" + + { + _, err := LoadConfigFile(tomlPath, now) + failUnlessExpectedError(t, err, "toml: expected character ]", "reading %s", tomlPath) + } + + { + _, err := LoadConfig(tomlPath, nil) + failUnlessExpectedError(t, err, "toml: expected character ]", "reading %s", tomlPath) + } +} + +func TestUnknownZoneConfigFile(t *testing.T) { + now := time.Now() + tomlPath := "./testdata/config_file/unknown_zone.toml" + _, err := LoadConfigFile(tomlPath, now) + failUnlessExpectedError(t, err, "unknown time zone !", "reading %s", tomlPath) +} diff --git a/example-conf.toml b/example-conf.toml index d424524..1407a10 100644 --- a/example-conf.toml +++ b/example-conf.toml @@ -1,6 +1,5 @@ [[zones]] id = "NZ" -name = "NZ" [[zones]] id = "Australia/Sydney" @@ -11,7 +10,7 @@ id = "Asia/Kolkata" name = "Bangalore" [[zones]] -id = "UTC" +id = "" name = "UTC" [keymaps] diff --git a/testdata/config_file/empty.toml b/testdata/config_file/empty.toml new file mode 100644 index 0000000..e69de29 diff --git a/testdata/config_file/invalid.toml b/testdata/config_file/invalid.toml new file mode 100644 index 0000000..8ad0c3b --- /dev/null +++ b/testdata/config_file/invalid.toml @@ -0,0 +1 @@ +[[foo diff --git a/testdata/config_file/unknown_zone.toml b/testdata/config_file/unknown_zone.toml new file mode 100644 index 0000000..8c58576 --- /dev/null +++ b/testdata/config_file/unknown_zone.toml @@ -0,0 +1,2 @@ +[[zones]] +id = "!"