diff --git a/scripts/test-sync-order-ui.mjs b/scripts/test-sync-order-ui.mjs new file mode 100644 index 0000000..1765dbb --- /dev/null +++ b/scripts/test-sync-order-ui.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; + +import { chromium } from "playwright-core"; + +const baseUrl = process.env.CODEX_SWITCHER_UI_URL ?? "http://127.0.0.1:3210"; + +function findChromeExecutable() { + const candidates = [ + process.env.CHROME_PATH, + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", + "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", + ].filter(Boolean); + + for (const candidate of candidates) { + if (candidate && fs.existsSync(candidate)) { + return candidate; + } + } + + throw new Error("No Chrome/Edge executable found for UI sync-order test"); +} + +async function waitForRefreshIdle(page) { + await page.waitForFunction(() => { + const button = Array.from(document.querySelectorAll("button")).find((element) => { + const label = element.textContent?.trim(); + return label === "Refresh All" || label === "Refreshing..."; + }); + + return button?.textContent?.trim() === "Refresh All" && !button.hasAttribute("disabled"); + }, undefined, { timeout: 60000 }); +} + +async function runRefreshCycle(page) { + await waitForRefreshIdle(page); + + const refreshAllButton = page.getByRole("button", { name: "Refresh All" }); + await refreshAllButton.click(); + + await page.waitForFunction(() => { + const button = Array.from(document.querySelectorAll("button")).find((element) => { + const label = element.textContent?.trim(); + return label === "Refresh All" || label === "Refreshing..."; + }); + + return button?.textContent?.trim() === "Refreshing..." || button?.hasAttribute("disabled"); + }, undefined, { timeout: 5000 }).catch(() => {}); + + await waitForRefreshIdle(page); + await page.waitForTimeout(500); +} + +async function getOtherAccountOrder(page) { + const section = page.locator("section").filter({ + has: page.getByText(/Other Accounts \(\d+\)/), + }); + + await section.first().waitFor(); + + const cardTitles = section.locator("div.theme-card h3"); + const names = await cardTitles.allTextContents(); + return names.map((name) => name.trim()).filter(Boolean); +} + +const browser = await chromium.launch({ + executablePath: findChromeExecutable(), + headless: true, +}); + +try { + const page = await browser.newPage(); + await page.addInitScript(() => { + const originalSetInterval = window.setInterval.bind(window); + + window.setInterval = (handler, timeout, ...args) => { + if (timeout === 5000 || timeout === 60000) { + return 0; + } + + return originalSetInterval(handler, timeout, ...args); + }; + }); + await page.goto(baseUrl, { waitUntil: "domcontentloaded" }); + await page.getByRole("button", { name: "Refresh All" }).waitFor(); + const samples = []; + + await runRefreshCycle(page); + + const baselineOrder = await getOtherAccountOrder(page); + samples.push(`baseline: ${baselineOrder.join(" | ")}`); + + for (let round = 1; round <= 4; round += 1) { + await runRefreshCycle(page); + + const order = await getOtherAccountOrder(page); + samples.push(`round ${round}: ${order.join(" | ")}`); + assert.deepEqual(order, baselineOrder, samples.join("\n")); + } + + console.log(samples.join("\n")); +} finally { + await browser.close(); +} diff --git a/src-tauri/src/api/usage.rs b/src-tauri/src/api/usage.rs index e069545..58bea81 100644 --- a/src-tauri/src/api/usage.rs +++ b/src-tauri/src/api/usage.rs @@ -126,8 +126,7 @@ async fn warmup_with_chatgpt_auth(account: &StoredAccount) -> Result<()> { let fresh_account = ensure_chatgpt_tokens_fresh(account).await?; let (access_token, chatgpt_account_id) = extract_chatgpt_auth(&fresh_account)?; - let mut response = - send_chatgpt_warmup_request(access_token, chatgpt_account_id, true).await?; + let mut response = send_chatgpt_warmup_request(access_token, chatgpt_account_id, true).await?; if response.status() == StatusCode::UNAUTHORIZED { println!( "[Warmup] Unauthorized for account {}, refreshing token and retrying once", @@ -409,13 +408,13 @@ pub async fn refresh_all_usage(accounts: &[StoredAccount]) -> Vec { println!("[Usage] Refreshing usage for {} accounts", accounts.len()); let concurrency = accounts.len().min(10).max(1); - let results: Vec = stream::iter(accounts.iter().cloned()) - .map(|account| async move { + let mut results: Vec<(usize, UsageInfo)> = stream::iter(accounts.iter().cloned().enumerate()) + .map(|(index, account)| async move { match get_account_usage(&account).await { - Ok(info) => info, + Ok(info) => (index, info), Err(e) => { println!("[Usage] Error for {}: {}", account.name, e); - UsageInfo::error(account.id.clone(), e.to_string()) + (index, UsageInfo::error(account.id.clone(), e.to_string())) } } }) @@ -423,6 +422,8 @@ pub async fn refresh_all_usage(accounts: &[StoredAccount]) -> Vec { .collect() .await; + results.sort_by_key(|(index, _)| *index); + println!("[Usage] Refresh complete"); - results + results.into_iter().map(|(_, info)| info).collect() } diff --git a/src/App.tsx b/src/App.tsx index f0ffdaa..548e2a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,9 +3,13 @@ import { invoke } from "@tauri-apps/api/core"; import { open, save } from "@tauri-apps/plugin-dialog"; import { useAccounts } from "./hooks/useAccounts"; import { AccountCard, AddAccountModal, UpdateChecker } from "./components"; -import type { CodexProcessInfo } from "./types"; +import type { AccountWithUsage, CodexProcessInfo } from "./types"; +import { + areOtherAccountsLoading, + getOrderedOtherAccountIds, + type OtherAccountsSort, +} from "./lib/otherAccountsOrder"; import "./App.css"; - function App() { const { accounts, @@ -54,11 +58,11 @@ function App() { isError: boolean; } | null>(null); const [maskedAccounts, setMaskedAccounts] = useState>(new Set()); - const [otherAccountsSort, setOtherAccountsSort] = useState< - "deadline_asc" | "deadline_desc" | "remaining_desc" | "remaining_asc" - >("deadline_asc"); + const [otherAccountsSort, setOtherAccountsSort] = useState("deadline_asc"); + const [otherAccountsOrder, setOtherAccountsOrder] = useState([]); const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false); const actionsMenuRef = useRef(null); + const appliedOtherAccountsSortRef = useRef(null); const toggleMask = (accountId: string) => { setMaskedAccounts((prev) => { @@ -343,48 +347,64 @@ function App() { const otherAccounts = accounts.filter((a) => !a.is_active); const hasRunningProcesses = processInfo && processInfo.count > 0; - const sortedOtherAccounts = useMemo(() => { - const getResetDeadline = (resetAt: number | null | undefined) => - resetAt ?? Number.POSITIVE_INFINITY; + const otherAccountsStructureSignature = useMemo( + () => + [...otherAccounts] + .map((account) => `${account.id}:${account.name}:${account.is_active ? "1" : "0"}`) + .sort() + .join("|"), + [otherAccounts] + ); - const getRemainingPercent = (usedPercent: number | null | undefined) => { - if (usedPercent === null || usedPercent === undefined) { - return Number.NEGATIVE_INFINITY; - } - return Math.max(0, 100 - usedPercent); - }; + useEffect(() => { + if (otherAccounts.length === 0) { + setOtherAccountsOrder([]); + return; + } - return [...otherAccounts].sort((a, b) => { - if (otherAccountsSort === "deadline_asc" || otherAccountsSort === "deadline_desc") { - const deadlineDiff = - getResetDeadline(a.usage?.primary_resets_at) - - getResetDeadline(b.usage?.primary_resets_at); - if (deadlineDiff !== 0) { - return otherAccountsSort === "deadline_asc" ? deadlineDiff : -deadlineDiff; - } - const remainingDiff = - getRemainingPercent(b.usage?.primary_used_percent) - - getRemainingPercent(a.usage?.primary_used_percent); - if (remainingDiff !== 0) return remainingDiff; - return a.name.localeCompare(b.name); - } + setOtherAccountsOrder((currentOrder) => { + const currentIds = new Set(otherAccounts.map((account) => account.id)); + const retainedIds = currentOrder.filter((id) => currentIds.has(id)); + const retainedIdSet = new Set(retainedIds); + const appendedIds = otherAccounts + .map((account) => account.id) + .filter((id) => !retainedIdSet.has(id)); - const remainingDiff = - getRemainingPercent(b.usage?.primary_used_percent) - - getRemainingPercent(a.usage?.primary_used_percent); - if (otherAccountsSort === "remaining_desc" && remainingDiff !== 0) { - return remainingDiff; - } - if (otherAccountsSort === "remaining_asc" && remainingDiff !== 0) { - return -remainingDiff; - } - const deadlineDiff = - getResetDeadline(a.usage?.primary_resets_at) - - getResetDeadline(b.usage?.primary_resets_at); - if (deadlineDiff !== 0) return deadlineDiff; - return a.name.localeCompare(b.name); + return [...retainedIds, ...appendedIds]; }); - }, [otherAccounts, otherAccountsSort]); + }, [otherAccountsStructureSignature]); + + useEffect(() => { + const sortChanged = appliedOtherAccountsSortRef.current !== otherAccountsSort; + const needsInitialOrder = otherAccounts.length > 0 && otherAccountsOrder.length === 0; + + if (!sortChanged && !needsInitialOrder) { + return; + } + + if (areOtherAccountsLoading(otherAccounts)) { + return; + } + + setOtherAccountsOrder(getOrderedOtherAccountIds(otherAccounts, otherAccountsSort)); + appliedOtherAccountsSortRef.current = otherAccountsSort; + }, [otherAccounts, otherAccountsOrder.length, otherAccountsSort]); + + const sortedOtherAccounts = useMemo(() => { + const accountMap = new Map(otherAccounts.map((account) => [account.id, account])); + const orderedIds = + otherAccountsOrder.length === otherAccounts.length + ? otherAccountsOrder + : getOrderedOtherAccountIds(otherAccounts, otherAccountsSort); + const orderedAccounts = orderedIds + .map((id) => accountMap.get(id)) + .filter((account): account is AccountWithUsage => Boolean(account)); + + const orderedIdSet = new Set(orderedAccounts.map((account) => account.id)); + const appendedAccounts = otherAccounts.filter((account) => !orderedIdSet.has(account.id)); + + return [...orderedAccounts, ...appendedAccounts]; + }, [otherAccounts, otherAccountsOrder, otherAccountsSort]); return (
diff --git a/src/lib/otherAccountsOrder.ts b/src/lib/otherAccountsOrder.ts new file mode 100644 index 0000000..b4f9fd5 --- /dev/null +++ b/src/lib/otherAccountsOrder.ts @@ -0,0 +1,112 @@ +import type { AccountWithUsage } from "../types"; + +export type OtherAccountsSort = + | "deadline_asc" + | "deadline_desc" + | "remaining_desc" + | "remaining_asc"; + +function getResetMinuteBucket(resetAt: number | null | undefined): number { + if (resetAt === null || resetAt === undefined) { + return Number.POSITIVE_INFINITY; + } + + // The UI renders reset time at minute precision, so keep sorting at the same + // granularity to avoid apparent random reshuffles caused by second-level jitter. + return Math.floor(resetAt / 60); +} + +function getRemainingPercentBucket(usedPercent: number | null | undefined): number { + if (usedPercent === null || usedPercent === undefined) { + return Number.NEGATIVE_INFINITY; + } + + return Math.round(Math.max(0, 100 - usedPercent)); +} + +function compareResetBuckets( + aResetBucket: number, + bResetBucket: number, + sort: "deadline_asc" | "deadline_desc" +): number { + const resetDiff = aResetBucket - bResetBucket; + if (resetDiff === 0) { + return 0; + } + + return sort === "deadline_asc" ? resetDiff : -resetDiff; +} + +export function areOtherAccountsLoading(accounts: AccountWithUsage[]): boolean { + return accounts.some((account) => account.usageLoading); +} + +export function compareOtherAccounts( + a: AccountWithUsage, + b: AccountWithUsage, + sort: OtherAccountsSort +): number { + const aWeeklyResetBucket = getResetMinuteBucket(a.usage?.secondary_resets_at); + const bWeeklyResetBucket = getResetMinuteBucket(b.usage?.secondary_resets_at); + const aPrimaryResetBucket = getResetMinuteBucket(a.usage?.primary_resets_at); + const bPrimaryResetBucket = getResetMinuteBucket(b.usage?.primary_resets_at); + const aRemainingBucket = getRemainingPercentBucket(a.usage?.primary_used_percent); + const bRemainingBucket = getRemainingPercentBucket(b.usage?.primary_used_percent); + + if (sort === "deadline_asc" || sort === "deadline_desc") { + const weeklyResetDiff = compareResetBuckets(aWeeklyResetBucket, bWeeklyResetBucket, sort); + if (weeklyResetDiff !== 0) { + return weeklyResetDiff; + } + + const primaryResetDiff = compareResetBuckets(aPrimaryResetBucket, bPrimaryResetBucket, sort); + if (primaryResetDiff !== 0) { + return primaryResetDiff; + } + + return a.name.localeCompare(b.name); + } + + const remainingDiff = bRemainingBucket - aRemainingBucket; + if (sort === "remaining_desc" && remainingDiff !== 0) { + return remainingDiff; + } + if (sort === "remaining_asc" && remainingDiff !== 0) { + return -remainingDiff; + } + + const weeklyResetDiff = aWeeklyResetBucket - bWeeklyResetBucket; + if (weeklyResetDiff !== 0) { + return weeklyResetDiff; + } + + const primaryResetDiff = aPrimaryResetBucket - bPrimaryResetBucket; + if (primaryResetDiff !== 0) { + return primaryResetDiff; + } + + return a.name.localeCompare(b.name); +} + +export function buildOtherAccountsSortSignature(accounts: AccountWithUsage[]): string { + return accounts + .map((account) => + [ + account.id, + account.name, + account.is_active ? "1" : "0", + account.usageLoading ? "1" : "0", + getResetMinuteBucket(account.usage?.secondary_resets_at), + getResetMinuteBucket(account.usage?.primary_resets_at), + getRemainingPercentBucket(account.usage?.primary_used_percent), + ].join(":") + ) + .join("|"); +} + +export function getOrderedOtherAccountIds( + accounts: AccountWithUsage[], + sort: OtherAccountsSort +): string[] { + return [...accounts].sort((a, b) => compareOtherAccounts(a, b, sort)).map((account) => account.id); +} diff --git a/tests/otherAccountsOrder.test.ts b/tests/otherAccountsOrder.test.ts new file mode 100644 index 0000000..9bd8f06 --- /dev/null +++ b/tests/otherAccountsOrder.test.ts @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + areOtherAccountsLoading, + buildOtherAccountsSortSignature, + getOrderedOtherAccountIds, +} from "../src/lib/otherAccountsOrder.ts"; +import type { AccountWithUsage } from "../src/types/index.ts"; + +function makeAccount( + name: string, + options: { + id?: string; + usageLoading?: boolean; + primaryResetsAt?: number | null; + primaryUsedPercent?: number | null; + secondaryResetsAt?: number | null; + } = {} +): AccountWithUsage { + return { + id: options.id ?? name, + name, + email: `${name}@example.com`, + plan_type: "plus", + auth_mode: "chat_gpt", + is_active: false, + created_at: "2026-03-22T00:00:00.000Z", + last_used_at: null, + usageLoading: options.usageLoading ?? false, + usage: { + account_id: options.id ?? name, + plan_type: "plus", + primary_used_percent: options.primaryUsedPercent ?? 0, + primary_window_minutes: 300, + primary_resets_at: options.primaryResetsAt ?? null, + secondary_used_percent: 0, + secondary_window_minutes: 10080, + secondary_resets_at: options.secondaryResetsAt ?? null, + has_credits: false, + unlimited_credits: false, + credits_balance: "0", + error: null, + }, + }; +} + +test("minute-level signature ignores second jitter in weekly and 5h reset timestamps", () => { + const firstSample = [ + makeAccount("jeanna", { + primaryResetsAt: 1774180558, + primaryUsedPercent: 0, + secondaryResetsAt: 1774580558, + }), + makeAccount("miller", { + primaryResetsAt: 1774180557, + primaryUsedPercent: 0, + secondaryResetsAt: 1774580557, + }), + makeAccount("sharen", { + primaryResetsAt: 1774180556, + primaryUsedPercent: 0, + secondaryResetsAt: 1774580556, + }), + ]; + const secondSample = [ + makeAccount("jeanna", { + primaryResetsAt: 1774180559, + primaryUsedPercent: 0, + secondaryResetsAt: 1774580559, + }), + makeAccount("miller", { + primaryResetsAt: 1774180558, + primaryUsedPercent: 0, + secondaryResetsAt: 1774580558, + }), + makeAccount("sharen", { + primaryResetsAt: 1774180557, + primaryUsedPercent: 0, + secondaryResetsAt: 1774580557, + }), + ]; + + assert.equal( + buildOtherAccountsSortSignature(firstSample), + buildOtherAccountsSortSignature(secondSample) + ); + assert.deepEqual( + getOrderedOtherAccountIds(firstSample, "deadline_asc"), + getOrderedOtherAccountIds(secondSample, "deadline_asc") + ); +}); + +test("loading flag can freeze reordering until refresh completes", () => { + const accounts = [ + makeAccount("jeanna", { + usageLoading: true, + primaryResetsAt: 1774180559, + secondaryResetsAt: 1774580559, + }), + makeAccount("miller", { + primaryResetsAt: 1774180557, + secondaryResetsAt: 1774580557, + }), + ]; + + assert.equal(areOtherAccountsLoading(accounts), true); +}); + +test("deadline sort prioritizes weekly reset before 5h reset", () => { + const accounts = [ + makeAccount("sharen", { + primaryResetsAt: 1774180200, + secondaryResetsAt: 1774581200, + }), + makeAccount("jeanna", { + primaryResetsAt: 1774182000, + secondaryResetsAt: 1774580600, + }), + makeAccount("miller", { + primaryResetsAt: 1774180100, + secondaryResetsAt: 1774580900, + }), + ]; + + assert.deepEqual(getOrderedOtherAccountIds(accounts, "deadline_asc"), [ + "jeanna", + "miller", + "sharen", + ]); +}); + +test("deadline sort uses 5h reset when weekly reset matches", () => { + const accounts = [ + makeAccount("sharen", { + primaryResetsAt: 1774181200, + secondaryResetsAt: 1774580600, + }), + makeAccount("jeanna", { + primaryResetsAt: 1774180300, + secondaryResetsAt: 1774580600, + }), + makeAccount("miller", { + primaryResetsAt: 1774180900, + secondaryResetsAt: 1774580600, + }), + ]; + + assert.deepEqual(getOrderedOtherAccountIds(accounts, "deadline_asc"), [ + "jeanna", + "miller", + "sharen", + ]); +}); + +test("deadline sort falls back to account name when both reset windows match", () => { + const tiedAccounts = [ + makeAccount("sharen", { + primaryResetsAt: 1774180556, + secondaryResetsAt: 1774580556, + }), + makeAccount("jeanna", { + primaryResetsAt: 1774180558, + secondaryResetsAt: 1774580558, + }), + makeAccount("miller", { + primaryResetsAt: 1774180557, + secondaryResetsAt: 1774580557, + }), + ]; + + assert.deepEqual(getOrderedOtherAccountIds(tiedAccounts, "deadline_asc"), [ + "jeanna", + "miller", + "sharen", + ]); +}); diff --git a/tests/syncOrder.api.test.ts b/tests/syncOrder.api.test.ts new file mode 100644 index 0000000..e556a9d --- /dev/null +++ b/tests/syncOrder.api.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getOrderedOtherAccountIds } from "../src/lib/otherAccountsOrder.ts"; +import type { AccountInfo, AccountWithUsage, UsageInfo } from "../src/types/index.ts"; + +const backendBaseUrl = process.env.CODEX_SWITCHER_BACKEND_URL ?? "http://127.0.0.1:3211"; + +async function invokeCommand(command: string, payload: Record = {}): Promise { + const response = await fetch(`${backendBaseUrl}/api/invoke/${command}`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`${command} failed with ${response.status}: ${body}`); + } + + return (await response.json()) as T; +} + +function summarizeOrder(accounts: AccountWithUsage[], ids: string[]): string { + const accountMap = new Map(accounts.map((account) => [account.id, account])); + + return ids + .map((id) => { + const account = accountMap.get(id); + if (!account) { + return `${id}:missing`; + } + + const weeklyResetMinute = + account.usage?.secondary_resets_at === null || account.usage?.secondary_resets_at === undefined + ? "inf" + : Math.floor(account.usage.secondary_resets_at / 60).toString(); + const primaryResetMinute = + account.usage?.primary_resets_at === null || account.usage?.primary_resets_at === undefined + ? "inf" + : Math.floor(account.usage.primary_resets_at / 60).toString(); + + return `${account.name}:weekly=${weeklyResetMinute}:primary=${primaryResetMinute}`; + }) + .join(" | "); +} + +function getResetMinute(resetAt: number | null | undefined): string { + return resetAt === null || resetAt === undefined ? "inf" : Math.floor(resetAt / 60).toString(); +} + +function summarizeEffectiveSortKeysByName(accounts: AccountWithUsage[]): string { + const weeklyResetCounts = new Map(); + + for (const account of accounts) { + const weeklyResetMinute = getResetMinute(account.usage?.secondary_resets_at); + weeklyResetCounts.set(weeklyResetMinute, (weeklyResetCounts.get(weeklyResetMinute) ?? 0) + 1); + } + + return [...accounts] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((account) => { + const weeklyResetMinute = getResetMinute(account.usage?.secondary_resets_at); + const primaryResetMinute = getResetMinute(account.usage?.primary_resets_at); + const needsPrimaryTieBreaker = (weeklyResetCounts.get(weeklyResetMinute) ?? 0) > 1; + + return needsPrimaryTieBreaker + ? `${account.name}:weekly=${weeklyResetMinute}:primary=${primaryResetMinute}` + : `${account.name}:weekly=${weeklyResetMinute}`; + }) + .join(" | "); +} + +test( + "repeated sync cycles keep other-account order stable for visible sort values", + { timeout: 120000 }, + async (t) => { + const health = await fetch(`${backendBaseUrl}/api/health`).catch(() => null); + if (!health?.ok) { + t.skip(`Backend is not reachable at ${backendBaseUrl}`); + return; + } + + const sampledOrders: string[][] = []; + const sampledSummaries: string[] = []; + const sampledFingerprints: string[] = []; + + for (let round = 0; round < 5; round += 1) { + await invokeCommand("sync_live_auth"); + const accounts = await invokeCommand("list_accounts"); + const otherAccounts = accounts.filter((account) => !account.is_active); + + assert.ok(otherAccounts.length >= 2, "Need at least two non-active accounts to verify order"); + + const accountsWithUsage: AccountWithUsage[] = await Promise.all( + otherAccounts.map(async (account) => { + const usage = await invokeCommand("get_usage", { account_id: account.id }); + return { + ...account, + usage, + usageLoading: false, + }; + }) + ); + + const orderedIds = getOrderedOtherAccountIds(accountsWithUsage, "deadline_asc"); + sampledOrders.push(orderedIds); + sampledSummaries.push(`round ${round + 1}: ${summarizeOrder(accountsWithUsage, orderedIds)}`); + sampledFingerprints.push(summarizeEffectiveSortKeysByName(accountsWithUsage)); + } + + const baselineOrder = sampledOrders[0]; + const baselineFingerprint = sampledFingerprints[0]; + let comparedRounds = 1; + + for (let round = 1; round < sampledOrders.length; round += 1) { + if (sampledFingerprints[round] !== baselineFingerprint) { + continue; + } + + comparedRounds += 1; + assert.deepEqual(sampledOrders[round], baselineOrder, sampledSummaries.join("\n")); + } + + assert.ok(comparedRounds >= 2, sampledSummaries.join("\n")); + } +);