diff --git a/README.md b/README.md index 201fdbe..be6656d 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Detailed command documentation lives in [docs/commands/README.md](./docs/command |---------|-------------| | [`codex-auth list [--live] [--active] [--api\|--skip-api]`](./docs/commands/list.md) | List stored accounts and usage state | | [`codex-auth login [--device-auth]`](./docs/commands/login.md) | Run `codex login`, then add the current account | -| [`codex-auth switch [--live] [--api\|--skip-api]`](./docs/commands/switch.md) | Switch the active account interactively | +| [`codex-auth switch [--auto\|--live] [--api\|--skip-api]`](./docs/commands/switch.md) | Switch the active account interactively or automatically | | [`codex-auth switch `](./docs/commands/switch.md) | Switch directly by row number or account selector | | [`codex-auth remove [--live] [--api\|--skip-api]`](./docs/commands/remove.md) | Remove accounts interactively | | [`codex-auth remove [...]`](./docs/commands/remove.md) | Remove accounts by selector | diff --git a/docs/commands/switch.md b/docs/commands/switch.md index ba81749..4ca30d7 100644 --- a/docs/commands/switch.md +++ b/docs/commands/switch.md @@ -4,6 +4,7 @@ ```shell codex-auth switch - +codex-auth switch --auto [--api|--skip-api] codex-auth switch [--api|--skip-api] codex-auth switch --live [--api|--skip-api] codex-auth switch @@ -27,6 +28,17 @@ codex-auth switch - `--api` forces foreground remote refresh before rendering. - `--skip-api` renders from stored data and local-only active-account refresh where available. +## Automatic One-Shot Switch + +`codex-auth switch --auto` switches once without opening a picker. + +- The command refreshes usage the same way as interactive switch unless `--skip-api` is set. +- It selects an inactive account only when both 5h and weekly remaining limits are greater than `0%`. +- When multiple inactive accounts are eligible, it chooses the account with the earliest 5h reset. +- Accounts with foreground refresh errors are not eligible for that run. +- The command exits after one successful switch and does not start a background process. +- `--auto` cannot be combined with `--live`, `-`, or a query target. + ## Live Switch `codex-auth switch --live` keeps the picker open after each successful switch. diff --git a/src/cli/commands/switch.zig b/src/cli/commands/switch.zig index 16f2eeb..59da97a 100644 --- a/src/cli/commands/switch.zig +++ b/src/cli/commands/switch.zig @@ -18,6 +18,14 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa opts.live = true; continue; } + if (std.mem.eql(u8, arg, "--auto")) { + if (opts.auto) { + freeTarget(allocator, opts.target); + return common.usageErrorResult(allocator, .switch_account, "duplicate `--auto` for `switch`.", .{}); + } + opts.auto = true; + continue; + } if (std.mem.eql(u8, arg, "--api")) { switch (opts.api_mode) { .default => opts.api_mode = .force_api, @@ -59,12 +67,21 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa else .{ .query = try allocator.dupe(u8, arg) }; } - if (opts.target != .picker and (opts.api_mode != .default or opts.live)) { + if (opts.auto and opts.live) { + freeTarget(allocator, opts.target); + return common.usageErrorResult( + allocator, + .switch_account, + "`--auto` cannot be combined with `--live` for `switch`.", + .{}, + ); + } + if (opts.target != .picker and (opts.api_mode != .default or opts.live or opts.auto)) { freeTarget(allocator, opts.target); return common.usageErrorResult( allocator, .switch_account, - "`switch -|` does not support `--live`, `--api`, or `--skip-api`.", + "`switch -|` does not support `--auto`, `--live`, `--api`, or `--skip-api`.", .{}, ); } diff --git a/src/cli/help.zig b/src/cli/help.zig index 70142ed..c6387d6 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -46,7 +46,7 @@ pub fn writeHelp( try writeCommandSummary(out, use_color, "export [] [--cpa]", "Export stored account auth files"); try writeCommandSummary(out, use_color, "switch", "Switch the active account"); try writeCommandDetail(out, use_color, "switch -"); - try writeCommandDetail(out, use_color, "switch [--live] [--api|--skip-api]"); + try writeCommandDetail(out, use_color, "switch [--auto|--live] [--api|--skip-api]"); try writeCommandDetail(out, use_color, "switch "); try writeCommandSummary(out, use_color, "remove", "Remove one or more accounts"); try writeCommandDetail(out, use_color, "remove [--live] [--api|--skip-api]"); @@ -209,6 +209,7 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { }, .switch_account => { try out.writeAll(" codex-auth switch -\n"); + try out.writeAll(" codex-auth switch --auto [--api|--skip-api]\n"); try out.writeAll(" codex-auth switch [--live] [--api|--skip-api]\n"); try out.writeAll(" codex-auth switch \n"); }, @@ -278,6 +279,7 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" --cpa Export CPA flat token JSON. Without this, exports Codex auth snapshots.\n"); }, .switch_account => { + try out.writeAll(" --auto Switch once to an inactive account with both 5h and weekly limits remaining.\n"); try out.writeAll(" --live Open the live switch UI.\n"); try out.writeAll(" --api Load usage and account data from APIs.\n"); try out.writeAll(" --skip-api Load usage and account data from local data only (may be inaccurate).\n"); @@ -353,6 +355,7 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void { }, .switch_account => { try out.writeAll(" codex-auth switch\n"); + try out.writeAll(" codex-auth switch --auto\n"); try out.writeAll(" codex-auth switch -\n"); try out.writeAll(" codex-auth switch --live\n"); try out.writeAll(" codex-auth switch --api\n"); @@ -398,6 +401,7 @@ fn writeNotesSectionStyled(out: *std.Io.Writer, use_color: bool, topic: HelpTopi switch (topic) { .switch_account => { try out.writeAll(" Targets can be `-`, aliases, emails, display numbers, or partial queries.\n"); + try out.writeAll(" `--auto` selects the eligible inactive account with the earliest 5h reset.\n"); }, .alias => { try out.writeAll(" Alias targets can be aliases, emails, display numbers, or partial queries.\n"); diff --git a/src/cli/output.zig b/src/cli/output.zig index df5054c..e66533d 100644 --- a/src/cli/output.zig +++ b/src/cli/output.zig @@ -250,6 +250,18 @@ pub fn printSwitchRequiresTtyError() !void { try out.flush(); } +pub fn printNoAutoSwitchCandidateError() !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + const out = stderr.out(); + const use_color = stderr.color_enabled; + try writeErrorPrefixTo(out, use_color); + try out.writeAll(" no inactive account has both 5h and weekly limits remaining.\n"); + try writeHintPrefixTo(out, use_color); + try out.writeAll(" Run `codex-auth list --api` to refresh usage, or add another account with available limits.\n"); + try out.flush(); +} + pub fn printListRequiresTtyError() !void { var stderr: io_util.Stderr = undefined; stderr.init(); diff --git a/src/cli/picker_auto.zig b/src/cli/picker_auto.zig index 5cb4697..6cfba08 100644 --- a/src/cli/picker_auto.zig +++ b/src/cli/picker_auto.zig @@ -41,7 +41,7 @@ fn autoSwitchCandidateIsBetter(candidate: *const registry.AccountRecord, best: * const candidate_weekly_reset = resetDistanceForMinutes(candidate.last_usage, 10080, false, now); const best_weekly_reset = resetDistanceForMinutes(best.last_usage, 10080, false, now); - return candidate_weekly_reset < best_weekly_reset; + return candidate_weekly_reset > best_weekly_reset; } fn bestAutoSwitchCandidateSelectableIndex( diff --git a/src/cli/types.zig b/src/cli/types.zig index 3bf3ab6..84c580d 100644 --- a/src/cli/types.zig +++ b/src/cli/types.zig @@ -32,6 +32,7 @@ pub const SwitchTarget = union(enum) { pub const SwitchOptions = struct { target: SwitchTarget = .picker, live: bool = false, + auto: bool = false, api_mode: ApiMode = .default, }; pub const RemoveOptions = struct { diff --git a/src/registry/account_ops.zig b/src/registry/account_ops.zig index 682a433..237f67d 100644 --- a/src/registry/account_ops.zig +++ b/src/registry/account_ops.zig @@ -408,6 +408,72 @@ pub fn selectBestAccountIndexByUsage(reg: *Registry) ?usize { return best_idx; } +pub fn selectAutoSwitchAccountIndexByLimits(reg: *Registry) ?usize { + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + return selectAutoSwitchAccountIndexByLimitsWithUsageOverridesAt(reg, null, now); +} + +pub fn selectAutoSwitchAccountIndexByLimitsWithUsageOverrides( + reg: *Registry, + usage_overrides: ?[]const ?[]const u8, +) ?usize { + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + return selectAutoSwitchAccountIndexByLimitsWithUsageOverridesAt(reg, usage_overrides, now); +} + +pub fn selectAutoSwitchAccountIndexByLimitsAt(reg: *Registry, now: i64) ?usize { + return selectAutoSwitchAccountIndexByLimitsWithUsageOverridesAt(reg, null, now); +} + +fn selectAutoSwitchAccountIndexByLimitsWithUsageOverridesAt( + reg: *Registry, + usage_overrides: ?[]const ?[]const u8, + now: i64, +) ?usize { + var best_idx: ?usize = null; + for (reg.accounts.items, 0..) |*rec, idx| { + if (reg.active_account_key) |active_key| { + if (std.mem.eql(u8, rec.account_key, active_key)) continue; + } + if (usageOverrideForAccount(usage_overrides, idx) != null) continue; + if (!accountHasPositiveFiveHourAndWeeklyLimits(rec, now)) continue; + + if (best_idx == null or autoSwitchAccountIsBetter(rec, ®.accounts.items[best_idx.?], now)) { + best_idx = idx; + } + } + return best_idx; +} + +fn usageOverrideForAccount(usage_overrides: ?[]const ?[]const u8, account_idx: usize) ?[]const u8 { + const overrides = usage_overrides orelse return null; + if (account_idx >= overrides.len) return null; + return overrides[account_idx]; +} + +fn accountHasPositiveFiveHourAndWeeklyLimits(rec: *const AccountRecord, now: i64) bool { + const rate_5h = resolveRateWindow(rec.last_usage, 300, true); + const rate_week = resolveRateWindow(rec.last_usage, 10080, false); + const rem_5h = remainingPercentAt(rate_5h, now) orelse return false; + const rem_week = remainingPercentAt(rate_week, now) orelse return false; + return rem_5h > 0 and rem_week > 0; +} + +fn autoSwitchAccountIsBetter(candidate: *const AccountRecord, best: *const AccountRecord, now: i64) bool { + const candidate_5h_reset = resetDistanceForWindow(resolveRateWindow(candidate.last_usage, 300, true), now); + const best_5h_reset = resetDistanceForWindow(resolveRateWindow(best.last_usage, 300, true), now); + if (candidate_5h_reset != best_5h_reset) return candidate_5h_reset < best_5h_reset; + + const candidate_weekly_reset = resetDistanceForWindow(resolveRateWindow(candidate.last_usage, 10080, false), now); + const best_weekly_reset = resetDistanceForWindow(resolveRateWindow(best.last_usage, 10080, false), now); + return candidate_weekly_reset > best_weekly_reset; +} + +fn resetDistanceForWindow(window: ?RateLimitWindow, now: i64) i64 { + const resets_at = if (window) |value| value.resets_at orelse return std.math.maxInt(i64) else return std.math.maxInt(i64); + return @max(resets_at - now, 0); +} + pub fn usageScoreAt(usage: ?RateLimitSnapshot, now: i64) ?i64 { const rate_5h = resolveRateWindow(usage, 300, true); const rate_week = resolveRateWindow(usage, 10080, false); diff --git a/src/registry/root.zig b/src/registry/root.zig index f17868f..3e4be68 100644 --- a/src/registry/root.zig +++ b/src/registry/root.zig @@ -126,6 +126,9 @@ pub fn syncActiveAccountFromAuth(allocator: std.mem.Allocator, codex_home: []con } pub const removeAccounts = account_ops.removeAccounts; pub const selectBestAccountIndexByUsage = account_ops.selectBestAccountIndexByUsage; +pub const selectAutoSwitchAccountIndexByLimits = account_ops.selectAutoSwitchAccountIndexByLimits; +pub const selectAutoSwitchAccountIndexByLimitsWithUsageOverrides = account_ops.selectAutoSwitchAccountIndexByLimitsWithUsageOverrides; +pub const selectAutoSwitchAccountIndexByLimitsAt = account_ops.selectAutoSwitchAccountIndexByLimitsAt; pub const usageScoreAt = account_ops.usageScoreAt; pub const remainingPercentAt = account_ops.remainingPercentAt; pub const resolveRateWindow = account_ops.resolveRateWindow; diff --git a/src/workflows/preflight.zig b/src/workflows/preflight.zig index f646286..6bfbb34 100644 --- a/src/workflows/preflight.zig +++ b/src/workflows/preflight.zig @@ -23,6 +23,7 @@ pub fn isHandledCliError(err: anyerror) bool { err == error.TuiOutputUnavailable or err == error.CurlRequired or err == error.SwitchSelectionRequiresTty or + err == error.NoAutoSwitchCandidate or err == error.AliasSelectionRequiresTty or err == error.InvalidAlias or err == error.DuplicateAlias or diff --git a/src/workflows/switch.zig b/src/workflows/switch.zig index 1082840..648d1b2 100644 --- a/src/workflows/switch.zig +++ b/src/workflows/switch.zig @@ -22,6 +22,7 @@ pub fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: .previous => return handleSwitchPrevious(allocator, codex_home, opts), .picker => {}, } + if (opts.auto) return handleSwitchAuto(allocator, codex_home, opts); { if (!opts.live) { @@ -107,6 +108,46 @@ pub fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: } } +fn handleSwitchAuto( + allocator: std.mem.Allocator, + codex_home: []const u8, + opts: cli.types.SwitchOptions, +) !void { + std.debug.assert(opts.target == .picker); + std.debug.assert(!opts.live); + std.debug.assert(opts.auto); + + var loaded = if (opts.api_mode == .skip_api) + try loadStoredSwitchSelectionDisplay( + allocator, + codex_home, + .switch_account, + opts.api_mode, + ) + else + try loadSwitchSelectionDisplay( + allocator, + codex_home, + opts.api_mode, + .switch_account, + true, + ); + defer loaded.display.deinit(allocator); + defer if (loaded.refresh_error_name) |name| allocator.free(name); + + const selected_account_idx = registry.selectAutoSwitchAccountIndexByLimitsWithUsageOverrides( + &loaded.display.reg, + loaded.display.usage_overrides, + ) orelse { + try cli.output.printNoAutoSwitchCandidateError(); + return error.NoAutoSwitchCandidate; + }; + const selected_account_key = loaded.display.reg.accounts.items[selected_account_idx].account_key; + try registry.activateAccountByKey(allocator, codex_home, &loaded.display.reg, selected_account_key); + try registry.saveRegistry(allocator, codex_home, &loaded.display.reg); + try cli.output.printSwitchedAccount(allocator, &loaded.display.reg, selected_account_key); +} + fn handleSwitchQuery( allocator: std.mem.Allocator, codex_home: []const u8, diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index b76b4e6..f5f35e4 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -410,7 +410,7 @@ test "Scenario: Given help when rendering then login and command help notes are const help = aw.written(); try std.testing.expect(std.mem.indexOf(u8, help, "Commands:") != null); try std.testing.expect(std.mem.indexOf(u8, help, "list [--live] [--active] [--api|--skip-api]") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "switch [--live] [--api|--skip-api]") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "switch [--auto|--live] [--api|--skip-api]") != null); try std.testing.expect(std.mem.indexOf(u8, help, "alias set ") != null); try std.testing.expect(std.mem.indexOf(u8, help, "config live --interval ") != null); try std.testing.expect(std.mem.indexOf(u8, help, "auto enable") == null); @@ -474,7 +474,8 @@ test "Scenario: Given switch command help when rendering then target forms and m try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth switch john@example.com") != null); try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth switch 02") != null); try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth switch work") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "Options:\n --live Open the live switch UI.") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "Options:\n --auto Switch once to an inactive account with both 5h and weekly limits remaining.") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "--live Open the live switch UI.") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Switch directly when the target resolves to one account.") != null); try std.testing.expect(std.mem.indexOf(u8, help, "If a target is ambiguous") == null); } @@ -1077,22 +1078,33 @@ test "Scenario: Given switch interactive with live flag when parsing then live m } } -test "Scenario: Given switch with removed auto flag when parsing then usage error is returned" { +test "Scenario: Given switch auto when parsing then auto mode is preserved" { const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "switch", "--live", "--auto" }; + const args = [_][:0]const u8{ "codex-auth", "switch", "--auto" }; var result = try cli.commands.parseArgs(gpa, &args); defer cli.commands.freeParseResult(gpa, &result); - try expectUsageError(result, .switch_account, "unknown flag `--auto`"); + switch (result) { + .command => |cmd| switch (cmd) { + .switch_account => |opts| { + try std.testing.expect(opts.auto); + try std.testing.expect(!opts.live); + try std.testing.expectEqual(cli.types.SwitchTarget.picker, opts.target); + try std.testing.expectEqual(cli.types.ApiMode.default, opts.api_mode); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } } -test "Scenario: Given switch with removed auto flag without live when parsing then usage error is returned" { +test "Scenario: Given switch auto with live when parsing then usage error is returned" { const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "switch", "--auto" }; + const args = [_][:0]const u8{ "codex-auth", "switch", "--live", "--auto" }; var result = try cli.commands.parseArgs(gpa, &args); defer cli.commands.freeParseResult(gpa, &result); - try expectUsageError(result, .switch_account, "unknown flag `--auto`"); + try expectUsageError(result, .switch_account, "`--auto` cannot be combined with `--live`"); } test "Scenario: Given switch query with live flag when parsing then usage error is returned" { @@ -1176,13 +1188,13 @@ test "Scenario: Given switch dash with skip-api flag when parsing then usage err try expectUsageError(result, .switch_account, "switch -|"); } -test "Scenario: Given switch query with removed auto flag when parsing then usage error is returned" { +test "Scenario: Given switch query with auto flag when parsing then usage error is returned" { const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "switch", "--live", "--auto", "02" }; + const args = [_][:0]const u8{ "codex-auth", "switch", "--auto", "02" }; var result = try cli.commands.parseArgs(gpa, &args); defer cli.commands.freeParseResult(gpa, &result); - try expectUsageError(result, .switch_account, "unknown flag `--auto`"); + try expectUsageError(result, .switch_account, "does not support `--auto`"); } test "Scenario: Given switch with duplicate target when parsing then usage error is returned" { diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index 50af228..0344734 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -2852,7 +2852,7 @@ test "Scenario: Given switch query with api flag when running switch then it ret try expectFailure(result); try std.testing.expectEqualStrings("", result.stdout); - try std.testing.expect(std.mem.indexOf(u8, result.stderr, "does not support `--live`, `--api`, or `--skip-api`") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "does not support `--auto`, `--live`, `--api`, or `--skip-api`") != null); } test "Scenario: Given switch query with skip-api flag when running switch then it returns a usage error" { @@ -2888,7 +2888,85 @@ test "Scenario: Given switch query with skip-api flag when running switch then i try expectFailure(result); try std.testing.expectEqualStrings("", result.stdout); - try std.testing.expect(std.mem.indexOf(u8, result.stderr, "does not support `--live`, `--api`, or `--skip-api`") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "does not support `--auto`, `--live`, `--api`, or `--skip-api`") != null); +} + +test "Scenario: Given switch auto with eligible backup when running switch then it activates and saves the backup account" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + try seedRegistryWithAccounts(gpa, home_root, "active@example.com", &[_]SeedAccount{ + .{ .email = "active@example.com", .alias = "active" }, + .{ .email = "backup@example.com", .alias = "backup" }, + }); + try setStoredUsageSnapshotForAccount( + gpa, + home_root, + "backup@example.com", + makeUsageSnapshot(10.0, 20.0), + 1, + 0, + ); + + const codex_home = try codexHomeAlloc(gpa, home_root); + defer gpa.free(codex_home); + const active_auth_path = try authJsonPathAlloc(gpa, home_root); + defer gpa.free(active_auth_path); + + const active_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); + defer gpa.free(active_key); + const backup_key = try fixtures.accountKeyForEmailAlloc(gpa, "backup@example.com"); + defer gpa.free(backup_key); + const active_snapshot_path = try registry.accountAuthPath(gpa, codex_home, active_key); + defer gpa.free(active_snapshot_path); + const backup_snapshot_path = try registry.accountAuthPath(gpa, codex_home, backup_key); + defer gpa.free(backup_snapshot_path); + + const active_auth = try fixtures.authJsonWithEmailPlan(gpa, "active@example.com", "team"); + defer gpa.free(active_auth); + const backup_auth = try fixtures.authJsonWithEmailPlan(gpa, "backup@example.com", "plus"); + defer gpa.free(backup_auth); + + try tmp.dir.writeFile(.{ .sub_path = ".codex/auth.json", .data = active_auth }); + try fs.cwd().writeFile(.{ .sub_path = active_snapshot_path, .data = active_auth }); + try fs.cwd().writeFile(.{ .sub_path = backup_snapshot_path, .data = backup_auth }); + + try tmp.dir.makePath("empty-bin"); + const empty_path = try tmp.dir.realpathAlloc(gpa, "empty-bin"); + defer gpa.free(empty_path); + + const result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + empty_path, + &[_][]const u8{ "switch", "--auto", "--skip-api" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + try std.testing.expectEqualStrings("Switched to backup(backup@example.com)\n", result.stdout); + try std.testing.expectEqualStrings("", result.stderr); + + const auth_after = try fixtures.readFileAlloc(gpa, active_auth_path); + defer gpa.free(auth_after); + try std.testing.expectEqualStrings(backup_auth, auth_after); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expect(loaded.active_account_key != null); + try std.testing.expect(loaded.previous_active_account_key != null); + try std.testing.expectEqualStrings(backup_key, loaded.active_account_key.?); + try std.testing.expectEqualStrings(active_key, loaded.previous_active_account_key.?); } test "Scenario: Given switch without api flags when running interactively then it requires api refresh executables by default" { diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index 4b7180b..43dfbe5 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -1061,6 +1061,35 @@ test "Scenario: Given active usage at zero when picking a live auto-switch targe try std.testing.expectEqualStrings("user-1::acc-3", target_key.?); } +test "Scenario: Given live auto-switch candidates with reset five hour windows when picking target then later weekly reset wins" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + + try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-2", "short@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-3", "long@example.com", "", .team); + reg.active_account_key = try gpa.dupe(u8, "user-1::acc-1"); + reg.accounts.items[0].last_usage = testUsageSnapshotWithResets(now, 100, 20, 3600, 7 * 24 * 3600); + reg.accounts.items[1].last_usage = testUsageSnapshotWithResets(now, 80, 80, -1, 60); + reg.accounts.items[2].last_usage = testUsageSnapshotWithResets(now, 80, 20, -1, 86_400); + + var rows = try buildSwitchRowsWithUsageOverrides(gpa, ®, null); + defer rows.deinit(gpa); + try filterErroredRowsFromSelectableIndices(gpa, &rows); + + const target_key = try maybeAutoSwitchTargetKeyAlloc(gpa, .{ + .reg = ®, + .usage_overrides = null, + }, &rows); + defer if (target_key) |value| gpa.free(value); + + try std.testing.expect(target_key != null); + try std.testing.expectEqualStrings("user-1::acc-3", target_key.?); +} + test "Scenario: Given active usage above zero when picking a live auto-switch target then no target is chosen" { const gpa = std.testing.allocator; var reg = makeTestRegistry(); diff --git a/tests/workflows_core_test.zig b/tests/workflows_core_test.zig index f0ff8af..7a490bf 100644 --- a/tests/workflows_core_test.zig +++ b/tests/workflows_core_test.zig @@ -1251,6 +1251,82 @@ test "Scenario: Given list refresh with team names missing under the same user w try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); } +test "Scenario: Given auto switch candidates when selecting by limits then positive windows and earliest five hour reset win" { + const gpa = std.testing.allocator; + var reg = makeRegistry(); + defer reg.deinit(gpa); + + try appendAccount(gpa, ®, primary_record_key, "active@example.com", "active", .team); + try appendAccount(gpa, ®, secondary_record_key, "blocked-5h@example.com", "blocked-5h", .team); + try appendAccount(gpa, ®, tertiary_record_key, "blocked-weekly@example.com", "blocked-weekly", .plus); + try appendAccount(gpa, ®, shared_user_id ++ "::candidate-late", "late@example.com", "late", .plus); + try appendAccount(gpa, ®, shared_user_id ++ "::candidate-early", "early@example.com", "early", .plus); + try registry.setActiveAccountKey(gpa, ®, primary_record_key); + + const now: i64 = 1_800_000_000; + reg.accounts.items[0].last_usage = .{ + .primary = .{ .used_percent = 1, .window_minutes = 300, .resets_at = now + 30 }, + .secondary = .{ .used_percent = 1, .window_minutes = 10080, .resets_at = now + 86_400 }, + .credits = null, + .plan_type = .team, + }; + reg.accounts.items[1].last_usage = .{ + .primary = .{ .used_percent = 100, .window_minutes = 300, .resets_at = now + 60 }, + .secondary = .{ .used_percent = 1, .window_minutes = 10080, .resets_at = now + 86_400 }, + .credits = null, + .plan_type = .team, + }; + reg.accounts.items[2].last_usage = .{ + .primary = .{ .used_percent = 1, .window_minutes = 300, .resets_at = now + 30 }, + .secondary = .{ .used_percent = 100, .window_minutes = 10080, .resets_at = now + 86_400 }, + .credits = null, + .plan_type = .plus, + }; + reg.accounts.items[3].last_usage = .{ + .primary = .{ .used_percent = 20, .window_minutes = 300, .resets_at = now + 3_600 }, + .secondary = .{ .used_percent = 20, .window_minutes = 10080, .resets_at = now + 86_400 }, + .credits = null, + .plan_type = .plus, + }; + reg.accounts.items[4].last_usage = .{ + .primary = .{ .used_percent = 95, .window_minutes = 300, .resets_at = now + 120 }, + .secondary = .{ .used_percent = 95, .window_minutes = 10080, .resets_at = now + 604_800 }, + .credits = null, + .plan_type = .plus, + }; + + const selected_idx = registry.selectAutoSwitchAccountIndexByLimitsAt(®, now) orelse return error.TestExpectedEqual; + try std.testing.expectEqual(@as(usize, 4), selected_idx); +} + +test "Scenario: Given auto switch candidates with reset five hour windows when selecting by limits then later weekly reset wins" { + const gpa = std.testing.allocator; + var reg = makeRegistry(); + defer reg.deinit(gpa); + + try appendAccount(gpa, ®, primary_record_key, "active@example.com", "active", .team); + try appendAccount(gpa, ®, shared_user_id ++ "::candidate-short-week", "short@example.com", "short", .plus); + try appendAccount(gpa, ®, shared_user_id ++ "::candidate-long-week", "long@example.com", "long", .plus); + try registry.setActiveAccountKey(gpa, ®, primary_record_key); + + const now: i64 = 1_800_000_000; + reg.accounts.items[1].last_usage = .{ + .primary = .{ .used_percent = 80, .window_minutes = 300, .resets_at = now - 1 }, + .secondary = .{ .used_percent = 80, .window_minutes = 10080, .resets_at = now + 60 }, + .credits = null, + .plan_type = .plus, + }; + reg.accounts.items[2].last_usage = .{ + .primary = .{ .used_percent = 80, .window_minutes = 300, .resets_at = now - 1 }, + .secondary = .{ .used_percent = 20, .window_minutes = 10080, .resets_at = now + 86_400 }, + .credits = null, + .plan_type = .plus, + }; + + const selected_idx = registry.selectAutoSwitchAccountIndexByLimitsAt(®, now) orelse return error.TestExpectedEqual; + try std.testing.expectEqual(@as(usize, 2), selected_idx); +} + test "Scenario: Given removed active account with remaining accounts when reconciling then the best usage account becomes active" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{});