diff options
Diffstat (limited to 'internal/tui')
-rw-r--r-- | internal/tui/modal_test.go | 470 | ||||
-rw-r--r-- | internal/tui/shared_test.go | 403 |
2 files changed, 873 insertions, 0 deletions
diff --git a/internal/tui/modal_test.go b/internal/tui/modal_test.go new file mode 100644 index 0000000..64957ce --- /dev/null +++ b/internal/tui/modal_test.go @@ -0,0 +1,470 @@ +package tui + +import ( + "context" + "database/sql" + "testing" + "time" + + "git.tjp.lol/punchcard/internal/queries" + _ "modernc.org/sqlite" +) + +func TestValidateAndParseEntryForm(t *testing.T) { + // Test timezone handling in modal forms + tests := []struct { + name string + setupData func(*queries.Queries) (entryID int64, err error) + formValues []string // Values for each form field in order + expectError bool + expectedStart string // Expected UTC format + expectedEnd string // Expected UTC format + }{ + { + name: "local time input converted to UTC storage", + setupData: func(q *queries.Queries) (int64, error) { + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + if err != nil { + return 0, err + } + + entry, err := q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ + Description: sql.NullString{String: "Test work", Valid: true}, + ClientID: client.ID, + }) + if err != nil { + return 0, err + } + + // Stop the entry so we can edit it + _, err = q.StopTimeEntry(context.Background()) + return entry.ID, err + }, + formValues: []string{ + "2024-08-22 14:30:00", // Start time (local) + "2024-08-22 16:45:00", // End time (local) + "TestClient", // Client + "", // Project + "Updated description", // Description + "125.50", // Rate + }, + expectError: false, + expectedStart: "2024-08-22 14:30:00", // Should be converted to UTC format + expectedEnd: "2024-08-22 16:45:00", // Should be converted to UTC format + }, + { + name: "invalid time format should error", + setupData: func(q *queries.Queries) (int64, error) { + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + if err != nil { + return 0, err + } + + entry, err := q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ + Description: sql.NullString{String: "Test work", Valid: true}, + ClientID: client.ID, + }) + if err != nil { + return 0, err + } + + _, err = q.StopTimeEntry(context.Background()) + return entry.ID, err + }, + formValues: []string{ + "invalid-time-format", // Invalid start time + "2024-08-22 16:45:00", // Valid end time + "TestClient", // Client + "", // Project + "Updated description", // Description + "125.50", // Rate + }, + expectError: true, + }, + { + name: "empty end time for completed entry should error", + setupData: func(q *queries.Queries) (int64, error) { + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + if err != nil { + return 0, err + } + + entry, err := q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ + Description: sql.NullString{String: "Test work", Valid: true}, + ClientID: client.ID, + }) + if err != nil { + return 0, err + } + + _, err = q.StopTimeEntry(context.Background()) + return entry.ID, err + }, + formValues: []string{ + "2024-08-22 14:30:00", // Start time + "", // Empty end time (trying to re-open completed entry) + "TestClient", // Client + "", // Project + "Updated description", // Description + "125.50", // Rate + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, _, cleanup := setupTestDB(t) + defer cleanup() + + // Setup test data + entryID, err := tt.setupData(q) + if err != nil { + t.Fatalf("Failed to setup test data: %v", err) + } + + // Create a modal with entry form + modal := &ModalBoxModel{ + Type: ModalTypeEntry, + editedID: entryID, + form: NewEntryEditorForm(), + } + + // Set form field values + for i, value := range tt.formValues { + if i < len(modal.form.fields) { + modal.form.fields[i].SetValue(value) + } + } + + // Create a minimal AppModel for testing + appModel := AppModel{ + queries: q, + } + + // Test validateAndParseEntryForm + params, hasErrors := modal.validateAndParseEntryForm(appModel) + + if tt.expectError { + if !hasErrors { + t.Errorf("Expected validation errors but got none") + } + return + } + + if hasErrors { + // Check which fields have errors for debugging + for i, field := range modal.form.fields { + if field.Err != nil { + t.Logf("Field %d error: %v", i, field.Err) + } + } + t.Errorf("Unexpected validation errors") + return + } + + // Verify the parameters were parsed correctly + if params.EntryID != entryID { + t.Errorf("Expected entry ID %d, got %d", entryID, params.EntryID) + } + + // Verify start time conversion + if tt.expectedStart != "" { + // Parse the expected local time and convert to UTC for comparison + expectedLocal, err := time.ParseInLocation(time.DateTime, tt.expectedStart, time.Local) + if err != nil { + t.Fatalf("Failed to parse expected start time: %v", err) + } + expectedUTC := expectedLocal.UTC().Format(time.DateTime) + + if params.StartTime != expectedUTC { + t.Errorf("Expected start time %s (UTC), got %s", expectedUTC, params.StartTime) + } + } + + // Verify end time conversion + if tt.expectedEnd != "" { + expectedLocal, err := time.ParseInLocation(time.DateTime, tt.expectedEnd, time.Local) + if err != nil { + t.Fatalf("Failed to parse expected end time: %v", err) + } + expectedUTC := expectedLocal.UTC().Format(time.DateTime) + + if params.EndTime != expectedUTC { + t.Errorf("Expected end time %s (UTC), got %s", expectedUTC, params.EndTime) + } + } + + // Verify rate was parsed correctly + if tt.formValues[5] != "" { + expectedRate := 12550 // 125.50 * 100 (converted to cents) + if !params.HourlyRate.Valid || params.HourlyRate.Int64 != int64(expectedRate) { + t.Errorf("Expected hourly rate %d cents, got %v", expectedRate, params.HourlyRate) + } + } + }) + } +} + +func TestFormTimezoneValidation(t *testing.T) { + // Test timezone-related validation in form fields + tests := []struct { + name string + fieldType string + value string + expectError bool + }{ + { + name: "valid timestamp format", + fieldType: "timestamp", + value: "2024-08-22 14:30:00", + expectError: false, + }, + { + name: "invalid timestamp format - missing seconds", + fieldType: "timestamp", + value: "2024-08-22 14:30", + expectError: true, + }, + { + name: "invalid timestamp format - wrong separator", + fieldType: "timestamp", + value: "2024/08/22 14:30:00", + expectError: true, + }, + { + name: "empty optional timestamp", + fieldType: "optional_timestamp", + value: "", + expectError: false, + }, + { + name: "valid optional timestamp", + fieldType: "optional_timestamp", + value: "2024-08-22 16:45:00", + expectError: false, + }, + { + name: "invalid optional timestamp", + fieldType: "optional_timestamp", + value: "not-a-timestamp", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var field FormField + + switch tt.fieldType { + case "timestamp": + field = newTimestampField("Test Field") + case "optional_timestamp": + field = newOptionalTimestampField("Test Optional Field") + default: + t.Fatalf("Unknown field type: %s", tt.fieldType) + } + + // Set the value and validate + field.SetValue(tt.value) + err := field.Validate(tt.value) + + if tt.expectError { + if err == nil { + t.Errorf("Expected validation error for value %q, but got none", tt.value) + } + } else { + if err != nil { + t.Errorf("Expected no validation error for value %q, but got: %v", tt.value, err) + } + } + }) + } +} + +func TestModalEntryEditingTimezone(t *testing.T) { + // Test that modal editing maintains timezone consistency + tests := []struct { + name string + setupData func(*queries.Queries) (int64, error) + }{ + { + name: "editing preserves UTC storage", + setupData: func(q *queries.Queries) (int64, error) { + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + if err != nil { + return 0, err + } + + // Create entry with known UTC time + _, err = q.DBTX().(*sql.DB).Exec(` + INSERT INTO time_entry (start_time, end_time, client_id, description) + VALUES (?, ?, ?, 'Original work') + `, "2024-08-22 18:00:00", "2024-08-22 19:30:00", client.ID) + if err != nil { + return 0, err + } + + // Get the entry ID + var entryID int64 + err = q.DBTX().(*sql.DB).QueryRow("SELECT id FROM time_entry ORDER BY id DESC LIMIT 1").Scan(&entryID) + return entryID, err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, db, cleanup := setupTestDB(t) + defer cleanup() + + entryID, err := tt.setupData(q) + if err != nil { + t.Fatalf("Failed to setup test data: %v", err) + } + + // Get the original entry to verify UTC storage + var originalStart, originalEnd string + err = db.QueryRow("SELECT start_time, end_time FROM time_entry WHERE id = ?", entryID).Scan(&originalStart, &originalEnd) + if err != nil { + t.Fatalf("Failed to get original entry: %v", err) + } + + // Parse and verify original times are in a valid time format + // SQLite might return different formats, so try multiple + formats := []string{ + time.DateTime, // "2006-01-02 15:04:05" + time.RFC3339, // "2006-01-02T15:04:05Z" + "2006-01-02T15:04:05", // ISO format without Z + } + + startParsed := false + for _, format := range formats { + if _, err := time.Parse(format, originalStart); err == nil { + startParsed = true + break + } + } + if !startParsed { + t.Errorf("Original start time not in a recognized format: %s", originalStart) + } + + endParsed := false + for _, format := range formats { + if _, err := time.Parse(format, originalEnd); err == nil { + endParsed = true + break + } + } + if !endParsed { + t.Errorf("Original end time not in a recognized format: %s", originalEnd) + } + + // The key insight is that the modal should accept local time input + // but store UTC time in the database, maintaining consistency + t.Logf("Original entry stored with start=%s, end=%s (should be UTC)", originalStart, originalEnd) + }) + } +} + +func TestTimezoneConsistencyAcrossEditing(t *testing.T) { + // Integration test: verify that editing a time entry maintains timezone consistency + // between display (local) and storage (UTC) + + q, db, cleanup := setupTestDB(t) + defer cleanup() + + // Create client + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ConsistencyTestClient", + }) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + // Create an entry with a known UTC time + utcStart := "2024-08-22 20:00:00" // 8 PM UTC + utcEnd := "2024-08-22 22:30:00" // 10:30 PM UTC + + _, err = db.Exec(` + INSERT INTO time_entry (start_time, end_time, client_id, description) + VALUES (?, ?, ?, 'Consistency test') + `, utcStart, utcEnd, client.ID) + if err != nil { + t.Fatalf("Failed to create entry: %v", err) + } + + // Get the entry ID + var entryID int64 + err = db.QueryRow("SELECT id FROM time_entry ORDER BY id DESC LIMIT 1").Scan(&entryID) + if err != nil { + t.Fatalf("Failed to get entry ID: %v", err) + } + + // Create modal for editing + modal := &ModalBoxModel{ + Type: ModalTypeEntry, + editedID: entryID, + form: NewEntryEditorForm(), + } + + // Simulate what the UI would do: convert UTC to local for display + startUTC, _ := time.Parse(time.DateTime, utcStart) + endUTC, _ := time.Parse(time.DateTime, utcEnd) + + startLocal := startUTC.Local().Format(time.DateTime) + endLocal := endUTC.Local().Format(time.DateTime) + + // Set form values as if user is editing (using local time display) + formValues := []string{ + startLocal, // Start time in local format + endLocal, // End time in local format + "ConsistencyTestClient", // Client + "", // Project + "Updated description", // Description + "100.00", // Rate + } + + for i, value := range formValues { + if i < len(modal.form.fields) { + modal.form.fields[i].SetValue(value) + } + } + + // Create minimal AppModel + appModel := AppModel{ + queries: q, + } + + // Validate and parse the form (should convert back to UTC) + params, hasErrors := modal.validateAndParseEntryForm(appModel) + if hasErrors { + for i, field := range modal.form.fields { + if field.Err != nil { + t.Logf("Field %d error: %v", i, field.Err) + } + } + t.Fatalf("Unexpected validation errors") + } + + // The parsed params should contain UTC times again + if params.StartTime != utcStart { + t.Errorf("Expected start time to round-trip to UTC: expected %s, got %s", utcStart, params.StartTime) + } + + if params.EndTime != utcEnd { + t.Errorf("Expected end time to round-trip to UTC: expected %s, got %s", utcEnd, params.EndTime) + } + + t.Logf("Timezone consistency verified: Local display (%s->%s) converts back to UTC storage (%s->%s)", + startLocal, endLocal, params.StartTime, params.EndTime) +} + diff --git a/internal/tui/shared_test.go b/internal/tui/shared_test.go new file mode 100644 index 0000000..1df3eb9 --- /dev/null +++ b/internal/tui/shared_test.go @@ -0,0 +1,403 @@ +package tui + +import ( + "context" + "database/sql" + "testing" + "time" + + "git.tjp.lol/punchcard/internal/queries" + _ "modernc.org/sqlite" +) + +// setupTestDB creates an in-memory SQLite database for testing +func setupTestDB(t *testing.T) (*queries.Queries, *sql.DB, func()) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("Failed to open in-memory sqlite db: %v", err) + } + + // Simple but complete schema setup for testing + schema := ` + CREATE TABLE time_entry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_time DATETIME NOT NULL, + end_time DATETIME, + description TEXT, + client_id INTEGER NOT NULL, + project_id INTEGER, + billable_rate INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE client ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + email TEXT, + billable_rate INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE project ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + client_id INTEGER NOT NULL, + billable_rate INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (client_id) REFERENCES client (id) + ); + CREATE TABLE contractor ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + label TEXT, + email TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + ` + _, err = db.Exec(schema) + if err != nil { + t.Fatalf("Failed to create test schema: %v", err) + } + + q := queries.New(db) + + cleanup := func() { + if err := db.Close(); err != nil { + t.Logf("error closing database: %v", err) + } + } + + return q, db, cleanup +} + +func TestMostRecentMonday(t *testing.T) { + tests := []struct { + name string + fromTime time.Time + wantDay time.Weekday + }{ + { + name: "from wednesday gets previous monday", + fromTime: time.Date(2024, time.August, 21, 15, 30, 0, 0, time.UTC), // Wednesday + wantDay: time.Monday, + }, + { + name: "from monday gets same monday", + fromTime: time.Date(2024, time.August, 19, 10, 0, 0, 0, time.UTC), // Monday + wantDay: time.Monday, + }, + { + name: "from sunday gets next monday (current week)", + fromTime: time.Date(2024, time.August, 18, 20, 0, 0, 0, time.UTC), // Sunday + wantDay: time.Monday, + }, + { + name: "timezone boundary - UTC time vs local time", + fromTime: time.Date(2024, time.August, 19, 2, 0, 0, 0, time.UTC), // Monday 2 AM UTC + wantDay: time.Monday, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := mostRecentMonday(tt.fromTime) + + if result.Weekday() != tt.wantDay { + t.Errorf("mostRecentMonday(%v) weekday = %v, want %v", tt.fromTime, result.Weekday(), tt.wantDay) + } + + // The function always converts to local time, so compare appropriately + localFromTime := tt.fromTime.Local() + + // For Sunday, the function returns Monday of the same week (which is after Sunday) + // For other days, it should return a Monday that's not after the input day + if localFromTime.Weekday() != time.Sunday { + if result.After(localFromTime) { + t.Errorf("mostRecentMonday(%v) = %v, should not be after local input time %v", tt.fromTime, result, localFromTime) + } + } + + // Verify result is within reasonable range (allowing Monday after Sunday) + daysDiff := localFromTime.Sub(result).Hours() / 24 + if daysDiff > 7 || daysDiff < -1.5 { // Allow for Monday after Sunday + t.Errorf("mostRecentMonday(%v) = %v, should be within reasonable range of local input %v, got %v days", tt.fromTime, result, localFromTime, daysDiff) + } + }) + } +} + +func TestMostRecentMondayTimezoneConsistency(t *testing.T) { + // Test that mostRecentMonday works consistently across timezones + utcTime := time.Date(2024, time.August, 20, 1, 0, 0, 0, time.UTC) // Tuesday 1 AM UTC + + tests := []struct { + name string + timezone string + offset int // hours from UTC + }{ + {"Pacific", "America/Los_Angeles", -8}, + {"Eastern", "America/New_York", -5}, + {"UTC", "UTC", 0}, + {"Tokyo", "Asia/Tokyo", 9}, + {"Sydney", "Australia/Sydney", 10}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + loc, err := time.LoadLocation(tt.timezone) + if err != nil { + t.Skipf("Timezone %s not available: %v", tt.timezone, err) + } + + localTime := utcTime.In(loc) + monday := mostRecentMonday(localTime) + + if monday.Weekday() != time.Monday { + t.Errorf("Expected Monday, got %v for timezone %s", monday.Weekday(), tt.timezone) + } + + // The function always returns local time, not the input timezone + if monday.Location() != time.Local { + t.Errorf("Expected Local timezone, got %v", monday.Location()) + } + }) + } +} + +func TestDateOnly(t *testing.T) { + tests := []struct { + name string + input time.Time + wantHour int + wantMin int + wantSec int + wantLoc *time.Location + }{ + { + name: "UTC time to date only", + input: time.Date(2024, time.August, 21, 15, 30, 45, 123456789, time.UTC), + wantHour: 0, + wantMin: 0, + wantSec: 0, + wantLoc: time.UTC, + }, + { + name: "Local time preserves timezone", + input: time.Date(2024, time.August, 21, 23, 59, 59, 0, time.Local), + wantHour: 0, + wantMin: 0, + wantSec: 0, + wantLoc: time.Local, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := dateOnly(tt.input) + + if result.Hour() != tt.wantHour { + t.Errorf("dateOnly(%v).Hour() = %d, want %d", tt.input, result.Hour(), tt.wantHour) + } + if result.Minute() != tt.wantMin { + t.Errorf("dateOnly(%v).Minute() = %d, want %d", tt.input, result.Minute(), tt.wantMin) + } + if result.Second() != tt.wantSec { + t.Errorf("dateOnly(%v).Second() = %d, want %d", tt.input, result.Second(), tt.wantSec) + } + if result.Location() != tt.wantLoc { + t.Errorf("dateOnly(%v).Location() = %v, want %v", tt.input, result.Location(), tt.wantLoc) + } + + // Verify same date + if result.Year() != tt.input.Year() || result.Month() != tt.input.Month() || result.Day() != tt.input.Day() { + t.Errorf("dateOnly(%v) changed date: got %v", tt.input, result) + } + }) + } +} + +func TestGetTimerInfoTimezoneHandling(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries, *sql.DB) error + expectActive bool + expectDuration bool // whether duration should be > 0 + }{ + { + name: "active timer duration calculation", + setupData: func(q *queries.Queries, db *sql.DB) error { + // Create client + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + if err != nil { + return err + } + + // Create active time entry (start_time in UTC) + _, err = db.Exec(` + INSERT INTO time_entry (start_time, client_id, description) + VALUES (datetime('now', 'utc', '-1 hour'), ?, 'Test work') + `, client.ID) + return err + }, + expectActive: true, + expectDuration: true, + }, + { + name: "no active timer falls back to most recent", + setupData: func(q *queries.Queries, db *sql.DB) error { + // Create client + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + if err != nil { + return err + } + + // Create completed time entry + _, err = db.Exec(` + INSERT INTO time_entry (start_time, end_time, client_id, description) + VALUES ( + datetime('now', 'utc', '-2 hours'), + datetime('now', 'utc', '-1 hour'), + ?, 'Completed work' + ) + `, client.ID) + return err + }, + expectActive: false, + expectDuration: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, db, cleanup := setupTestDB(t) + defer cleanup() + + // Setup test data + if err := tt.setupData(q, db); err != nil { + t.Fatalf("Failed to setup test data: %v", err) + } + + // Test getTimerInfo + info, err := getTimerInfo(context.Background(), q) + if err != nil { + t.Fatalf("getTimerInfo failed: %v", err) + } + + if info.IsActive != tt.expectActive { + t.Errorf("Expected IsActive=%v, got %v", tt.expectActive, info.IsActive) + } + + if tt.expectDuration { + if info.Duration <= 0 { + t.Errorf("Expected positive duration, got %v", info.Duration) + } + } + + // Verify StartTime timezone handling + if info.StartTime.IsZero() { + t.Error("Expected non-zero StartTime") + } + + // For active timers, verify the duration calculation makes sense + if info.IsActive && tt.expectDuration { + // The duration should be reasonable (we inserted 1 hour ago) + expectedMin := 55 * time.Minute // Allow some margin for test execution time + expectedMax := 65 * time.Minute + + if info.Duration < expectedMin || info.Duration > expectedMax { + t.Errorf("Expected duration between %v and %v, got %v", expectedMin, expectedMax, info.Duration) + } + } + }) + } +} + +func TestGetAppDataTimezoneFiltering(t *testing.T) { + // Test that getAppData correctly filters "Today" and "Week" totals using local timezone + tests := []struct { + name string + setupData func(*queries.Queries, *sql.DB) error + }{ + { + name: "today and week filtering uses local timezone", + setupData: func(q *queries.Queries, db *sql.DB) error { + // Create client + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "FilterTestClient", + }) + if err != nil { + return err + } + + now := time.Now() + today := now.Format("2006-01-02") + yesterday := now.AddDate(0, 0, -1).Format("2006-01-02") + + // Insert entries for today and yesterday in UTC + _, err = db.Exec(` + INSERT INTO time_entry (start_time, end_time, client_id, description) VALUES + (?, ?, ?, 'Today work'), + (?, ?, ?, 'Yesterday work') + `, + today+" 10:00:00", today+" 11:00:00", client.ID, + yesterday+" 10:00:00", yesterday+" 11:00:00", client.ID, + ) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, db, cleanup := setupTestDB(t) + defer cleanup() + + // Create a default contractor first + _, err := q.CreateContractor(context.Background(), queries.CreateContractorParams{ + Name: "Default Contractor", + Label: "Testing", + Email: "test@example.com", + }) + if err != nil { + t.Fatalf("Failed to create contractor: %v", err) + } + + if err := tt.setupData(q, db); err != nil { + t.Fatalf("Failed to setup test data: %v", err) + } + + // Test with empty filter to get all data + filter := HistoryFilter{ + StartDate: time.Now().AddDate(0, 0, -7), // Last 7 days + } + + _, info, stats, _, _, _, err := getAppData(context.Background(), q, filter) + if err != nil { + t.Fatalf("getAppData failed: %v", err) + } + + // Verify that we got some time stats + // The exact values depend on the current time and setup, so we just verify they're reasonable + if stats.TodayTotal < 0 { + t.Errorf("Expected non-negative today total, got %v", stats.TodayTotal) + } + + if stats.WeekTotal < stats.TodayTotal { + t.Errorf("Expected week total >= today total, got week=%v, today=%v", stats.WeekTotal, stats.TodayTotal) + } + + // If there's an active timer, verify it contributes to both today and week + if info.IsActive { + expectedContribution := info.Duration + // The stats should include active time, but exact verification is complex + // due to timing differences, so we just verify structure + if stats.TodayTotal == 0 && expectedContribution > 0 { + t.Log("Note: Active timer contribution might not be reflected in test due to timing") + } + } + }) + } +} + |