package app import ( "context" "log/slog" "os" "os/exec" "path/filepath" "testing" "time" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/datetime" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/preferences" ) func TestDateTimeFormats(t *testing.T) { // datetime initializes libc's locale before tests run. Use a child process // so these fixed English expectations do not depend on the build locale. if os.Getenv("CTHULHU_DATE_TIME_TEST_CHILD") != "1" { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestDateTimeFormats$", "-test.count=1") command.Env = append(os.Environ(), "LC_ALL=C", "CTHULHU_DATE_TIME_TEST_CHILD=1") if output, err := command.CombinedOutput(); err != nil { t.Fatalf("C-locale date/time test: %v\n%s", err, output) } return } now := time.Date(2026, 9, 17, 19, 5, 0, 0, time.UTC) for _, tc := range []struct { command input.Command format, want string }{ {input.PresentTime, "locale", "19:05:00"}, {input.PresentTime, "12-hour", "07:05 PM"}, {input.PresentTime, "12-hour-seconds", "07:05:00 PM"}, {input.PresentTime, "24-hour-seconds", "19:05:00"}, {input.PresentTime, "24-hour-seconds-words", "19 hours, 05 minutes and 00 seconds"}, {input.PresentTime, "24-hour", "19:05"}, {input.PresentTime, "24-hour-words", "19 hours and 05 minutes"}, {input.PresentDate, "locale", "09/17/26"}, {input.PresentDate, "numbers-dm", "17/09"}, {input.PresentDate, "numbers-md", "09/17"}, {input.PresentDate, "numbers-dmy", "17/09/2026"}, {input.PresentDate, "numbers-mdy", "09/17/2026"}, {input.PresentDate, "numbers-ymd", "2026/09/17"}, {input.PresentDate, "full-dm", "Thursday, 17 September"}, {input.PresentDate, "full-md", "Thursday, September 17"}, {input.PresentDate, "full-dmy", "Thursday, 17 September, 2026"}, {input.PresentDate, "full-mdy", "Thursday, September 17, 2026"}, {input.PresentDate, "full-ymd", "2026. September 17, Thursday"}, {input.PresentDate, "abbreviated-dm", "Thu, 17 Sep"}, {input.PresentDate, "abbreviated-md", "Thu, Sep 17"}, {input.PresentDate, "abbreviated-dmy", "Thu, 17 Sep, 2026"}, {input.PresentDate, "abbreviated-mdy", "Thu, Sep 17, 2026"}, {input.PresentDate, "abbreviated-ymd", "2026. Sep 17, Thu"}, } { settings := config.DefaultSettings() if tc.command == input.PresentTime { settings.TimeFormat = tc.format } else { settings.DateFormat = tc.format } queue := &fakeSpeechQueue{spoken: make(chan string, 1), interrupt: make(chan struct{}, 1)} presentDateTime(queue, settings, tc.command, now) if got := <-queue.spoken; got != tc.want { t.Errorf("%s: %q want %q", tc.format, got, tc.want) } if len(queue.interrupt) != 1 { t.Fatal("command did not interrupt") } } } func TestDateTimeOptionCounts(t *testing.T) { now := time.Date(2026, 9, 17, 19, 5, 0, 0, time.UTC) timeOptions := datetime.TimeOptions(now) dateOptions := datetime.DateOptions(now) wantTime := []string{ "locale", "12-hour", "12-hour-seconds", "24-hour-seconds", "24-hour-seconds-words", "24-hour", "24-hour-words", } wantDate := []string{ "locale", "numbers-dm", "numbers-md", "numbers-dmy", "numbers-mdy", "numbers-ymd", "full-dm", "full-md", "full-dmy", "full-mdy", "full-ymd", "abbreviated-dm", "abbreviated-md", "abbreviated-dmy", "abbreviated-mdy", "abbreviated-ymd", } if len(timeOptions) != len(wantTime) { t.Fatalf("time options: got %d, want %d", len(timeOptions), len(wantTime)) } if len(dateOptions) != len(wantDate) { t.Fatalf("date options: got %d, want %d", len(dateOptions), len(wantDate)) } for _, test := range []struct { name string options []datetime.Option expected []string }{ {"time", timeOptions, wantTime}, {"date", dateOptions, wantDate}, } { for index, option := range test.options { if option.Value != test.expected[index] { t.Fatalf("%s option %d = %q, want %q", test.name, index, option.Value, test.expected[index]) } } } for _, option := range append(timeOptions, dateOptions...) { if option.Value == "" || option.Label == "" { t.Fatalf("empty option: %+v", option) } } } func TestDateTimePreferencesLiveAndSaved(t *testing.T) { settings := config.DefaultSettings() settings.Path = filepath.Join(t.TempDir(), "settings.toml") live := &liveSettings{} live.Store(settings) worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) worker.live = live values := preferences.FromSettings(settings) values.TimeFormat, values.DateFormat = "24-hour-words", "abbreviated-mdy" response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true, Close: true}) if !response.Accepted || !response.Close { t.Fatalf("apply: %+v", response) } loaded, err := config.Load(settings.Path) if err != nil || loaded.TimeFormat != "24-hour-words" || loaded.DateFormat != "abbreviated-mdy" { t.Fatalf("saved formats: %+v %v", loaded, err) } queue := &fakeSpeechQueue{spoken: make(chan string, 1)} presentDateTime(queue, live.Load(), input.PresentTime, time.Date(2026, 9, 17, 0, 1, 0, 0, time.Local)) if got := <-queue.spoken; got != "00 hours and 01 minutes" { t.Fatalf("live time: %s", got) } for _, field := range []string{"time", "date"} { bad := values if field == "time" { bad.TimeFormat = "invalid" } else { bad.DateFormat = "invalid" } if worker.apply(context.Background(), preferences.Request{Values: bad, Save: true}).Accepted { t.Fatal("invalid format accepted") } } if live.Load().TimeFormat != "24-hour-words" || live.Load().DateFormat != "abbreviated-mdy" { t.Fatal("invalid draft changed live settings") } }