diff --git a/README.md b/README.md index 9d80006..72bc76a 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,9 @@ Alternatively set `sync_base` to the `SYNC_BASE` of a self-hosted Anki sync serv ## Streak freezes -Streak freezes are off by default and start at zero. Open **Settings → ankiquest → Streak protection** in AnkiDroid, or choose **Manage streak freezes** on your dashboard profile, to opt in. The updated AnkiDroid dashboard reuses your saved token for your own player; its toolbar's **Settings** action opens all AnkiQuest preferences. In a standalone browser, connect once to use streak, deck, and Community settings. Supported servers reuse your account session across pages; older servers reuse the token only within the current page. Complete all three daily quests while enabled to earn one freeze per Anki day, up to three stored. This replaces the automatic freeze awarded every seven study days. On an existing server, previously protected days and their streak/XP history are preserved; unused automatic freezes are cleared on the upgrade's Anki day. Enabling freezes does not award any for earlier quest completions, including earlier today; completing extra reviews or toggling the setting cannot claim that day's reward again. Completing the quests with a full inventory does not bank a fourth freeze for later. +Streak freezes are on by default and start at zero. Open **Settings → ankiquest → Streak protection** in AnkiDroid, or choose **Manage streak freezes** on your dashboard profile, to turn protection off or back on. The updated AnkiDroid dashboard reuses your saved token for your own player; its toolbar's **Settings** action opens all AnkiQuest preferences. In a standalone browser, connect once to use streak, deck, and Community settings. Supported servers reuse your account session across pages; older servers reuse the token only within the current page. Complete all three daily quests while enabled to earn one freeze per Anki day, up to three stored. This replaces the automatic freeze awarded every seven study days. On an existing server, previously protected days and their streak/XP history are preserved; unused automatic freezes are cleared on the upgrade's Anki day. Re-enabling freezes does not award any for quests completed while protection was off, including earlier today; completing extra reviews or toggling the setting cannot claim that day's reward again. Completing the quests with a full inventory does not bank a fourth freeze for later. + +When upgrading from the opt-in default, protection turns on for players without a saved choice. Recorded opt-outs remain off, and saved freezes and past protection are preserved. The server saves the rollout time once; earlier quest completions are not retroactively rewarded, and restarting does not move that boundary. When an Anki day ends with no reviews, one available freeze automatically protects an existing streak. A protected day preserves the streak count without adding a study day. Consecutive missed days each need one freeze; once there is none available, the next missed day resets the streak. Turning freezes off pauses earning and spending, keeps stored freezes, and leaves previously protected days intact. Enabling them again cannot repair days missed while they were off. Day boundaries follow the player's Anki timezone and rollover, not midnight on the server. diff --git a/src/freezes.rs b/src/freezes.rs index 26a6adb..aaea33d 100644 --- a/src/freezes.rs +++ b/src/freezes.rs @@ -19,8 +19,18 @@ pub fn initialize(conn: &Connection) -> Result<(), Error> { ); create table if not exists freeze_legacy_players ( user text primary key + ); + create table if not exists freeze_defaults ( + id integer primary key check (id = 1), + enabled_from integer not null );", )?; + // No preference now means enabled. Keep a fixed boundary so upgrading an + // opt-in server does not retroactively earn/spend freezes or rewrite XP. + tx.execute( + "insert or ignore into freeze_defaults (id, enabled_from) values (1, ?1)", + [crate::now_ms()], + )?; // Existing review histories keep protection already applied by the old rule. // New databases never use that rule. This boundary is fixed across restarts. let initialized = tx.execute( @@ -47,6 +57,11 @@ impl Store { [user], |r| r.get(0), )?, + default_enabled_from: Some(self.conn.query_row( + "select enabled_from from freeze_defaults where id = 1", + [], + |r| r.get(0), + )?), preferences: self.freeze_preferences(user)?, }) } @@ -76,7 +91,12 @@ impl Store { |r| Ok((r.get(0)?, r.get(1)?)), ) .optional()?; - if previous.is_some_and(|(_, value)| value) != enabled { + let default_enabled_from: i64 = tx.query_row( + "select enabled_from from freeze_defaults where id = 1", + [], + |r| r.get(0), + )?; + if previous.map_or(at >= default_enabled_from, |(_, value)| value) != enabled { tx.execute( "insert into freeze_preferences (user, at, enabled) values (?1, ?2, ?3)", params![user, at.max(previous.map_or(at, |(at, _)| at)), enabled], @@ -160,7 +180,59 @@ mod tests { } #[test] - fn preferences_default_off_are_private_and_survive_restart() { + fn opt_out_rollout_keeps_its_boundary_and_saved_choices_across_restarts() { + let (store, path) = temporary_store(); + store.conn.execute_batch( + "drop table freeze_defaults; + insert into freeze_preferences (user, at, enabled) values ('cerro', 10, 0), ('hill', 10, 1);" + ).unwrap(); + drop(store); + let store = crate::store::Store::open(&path).unwrap(); + let default = store.freeze_policy("new-player").unwrap(); + let boundary = default.default_enabled_from.unwrap(); + assert!(!default.enabled_at(boundary - 1)); + assert!(default.enabled_at(boundary)); + assert!(!store.freeze_policy("cerro").unwrap().enabled_at(boundary)); + assert!(store.freeze_policy("hill").unwrap().enabled_at(10)); + assert_eq!(store.freeze_preferences("cerro").unwrap().len(), 1); + drop(store); + let store = crate::store::Store::open(&path).unwrap(); + assert_eq!( + store + .freeze_policy("new-player") + .unwrap() + .default_enabled_from, + Some(boundary) + ); + assert!( + !store + .freeze_policy("cerro") + .unwrap() + .enabled_at(crate::now_ms()) + ); + drop(store); + std::fs::remove_dir_all(path).unwrap(); + } + + #[test] + fn first_opt_out_is_saved_even_without_uploaded_reviews() { + let (mut store, path) = temporary_store(); + let at = crate::now_ms(); + store.set_freezes_enabled("cerro", false, at).unwrap(); + store.set_freezes_enabled("cerro", false, at + 1).unwrap(); + let before = store.freeze_preferences("cerro").unwrap(); + assert_eq!(before.len(), 1); + assert!(!before[0].enabled); + drop(store); + let store = crate::store::Store::open(&path).unwrap(); + assert_eq!(store.freeze_preferences("cerro").unwrap(), before); + assert!(store.freeze_preferences("hill").unwrap().is_empty()); + drop(store); + std::fs::remove_dir_all(path).unwrap(); + } + + #[test] + fn preference_changes_before_rollout_are_private_and_survive_restart() { let (mut store, path) = temporary_store(); assert!(store.freeze_preferences("cerro").unwrap().is_empty()); store.set_freezes_enabled("cerro", false, 10).unwrap(); diff --git a/src/game.rs b/src/game.rs index d575682..b568a80 100644 --- a/src/game.rs +++ b/src/game.rs @@ -44,12 +44,21 @@ pub struct FreezePolicy { /// First migration time for an existing database; earlier Anki days retain /// their old automatic protection. New databases have no legacy period. pub legacy_until: Option, + /// Saved rollout time: changing the default must not rewrite earlier rewards. + /// An unrestricted default policy enables protection throughout its history. + pub default_enabled_from: Option, pub preferences: Vec, } -fn freezes_enabled_at(preferences: &[FreezePreference], at: i64) -> bool { - let end = preferences.partition_point(|change| change.at <= at); - end > 0 && preferences[end - 1].enabled +impl FreezePolicy { + pub fn enabled_at(&self, at: i64) -> bool { + let end = self.preferences.partition_point(|change| change.at <= at); + if end > 0 { + self.preferences[end - 1].enabled + } else { + self.default_enabled_from.is_none_or(|start| at >= start) + } + } } #[derive(Clone, Copy, Debug, PartialEq, Deserialize)] @@ -1052,7 +1061,6 @@ pub fn compute_with_freezes( now_ms: i64, freeze_policy: &FreezePolicy, ) -> Profile { - let freeze_preferences = &freeze_policy.preferences; let (days, last_combo, earned) = collect_days_with_quests(reviews, clock, seed_of(user)); let today = clock.day(now_ms); let legacy_until = freeze_policy.legacy_until.map(|at| clock.day(at)); @@ -1082,7 +1090,7 @@ pub fn compute_with_freezes( let legacy = legacy_until.is_some_and(|cutoff| day < cutoff); if legacy_until == Some(day) { // Preserve previously used protection and its streak/XP history, - // but begin the opt-in system with an empty bank. + // but begin the quest-earned system with an empty bank. freezes = 0; } let stats = days.get(&day); @@ -1122,8 +1130,7 @@ pub fn compute_with_freezes( } else if day != today { if streak > 0 && freezes > 0 - && (legacy - || freezes_enabled_at(freeze_preferences, clock.day_start_ms(day + 1) - 1)) + && (legacy || freeze_policy.enabled_at(clock.day_start_ms(day + 1) - 1)) { freezes -= 1; frozen.insert(day); @@ -1147,9 +1154,8 @@ pub fn compute_with_freezes( // including when protection is off or the bank is already full. if !legacy && freezes < MAX_FREEZES - && s.quests_completed_at.is_some_and(|at| { - at <= now_ms && freezes_enabled_at(freeze_preferences, at) - }) + && s.quests_completed_at + .is_some_and(|at| at <= now_ms && freeze_policy.enabled_at(at)) { freezes += 1; freeze_earned_today = day == today; @@ -1338,7 +1344,7 @@ pub fn compute_with_freezes( let now = days.get(&today).unwrap_or(&empty); let at_risk = streak > 0 && !days.contains_key(&today); let day_ends_at = clock.day_start_ms(today + 1); - let effective_freezes = if freezes_enabled_at(freeze_preferences, now_ms) { + let effective_freezes = if freeze_policy.enabled_at(now_ms) { freezes } else { 0 @@ -1365,7 +1371,7 @@ pub fn compute_with_freezes( streak_state, freezes: effective_freezes, stored_freezes: freezes, - freezes_enabled: freezes_enabled_at(freeze_preferences, now_ms), + freezes_enabled: freeze_policy.enabled_at(now_ms), freeze_earned_today, at_risk, day_ends_at, @@ -1727,15 +1733,85 @@ mod tests { } #[test] - fn freezes_default_off_and_start_empty_without_historical_rewards() { + fn freezes_default_on_and_earn_from_quests_without_a_settings_change() { + let empty = protected(&[], at(1), &[]); + assert!(empty.freezes_enabled); + assert_eq!(empty.stored_freezes, 0); + let reviews = perfect_history(&[1]); + let earned = protected(&reviews, at(1), &[]); + assert!(earned.freezes_enabled); + assert_eq!(earned.freezes, 1); + assert!(earned.freeze_earned_today); + let paused = protected(&reviews, at(1), &[protection(at(1), false)]); + assert!(!paused.freezes_enabled); + assert_eq!(paused.stored_freezes, 1); + assert_eq!(paused.freezes, 0); + let covered = protected(&reviews, at(3), &[]); + assert_eq!(covered.streak, 1); + assert_eq!(covered.heatmap.iter().filter(|cell| cell.frozen).count(), 1); + } + + #[test] + fn freeze_default_rollout_does_not_backfill_and_keeps_explicit_opt_outs() { + let mut reviews = perfect_history(&[1, 2]); + let policy = FreezePolicy { + default_enabled_from: Some(utc().day_start_ms(3)), + ..FreezePolicy::default() + }; + let run = |reviews: &[Review], now, policy: &FreezePolicy| { + compute_with_freezes("a", "a", reviews, &utc(), &Week::default(), now, policy) + }; + let boundary = policy.default_enabled_from.unwrap(); + let old = run(&reviews, boundary - 1, &policy); + let enabled = run(&reviews, boundary, &policy); + assert!(!old.freezes_enabled); + assert!(enabled.freezes_enabled); + assert_eq!(enabled.stored_freezes, 0); + assert_eq!(enabled.xp_total, old.xp_total); + assert_eq!(enabled.streak, old.streak); + let earlier = run(&reviews, at(2), &policy); + let old_rule = run( + &reviews, + at(2), + &FreezePolicy { + preferences: vec![protection(0, false)], + ..FreezePolicy::default() + }, + ); + assert_eq!( + serde_json::to_value(earlier).unwrap(), + serde_json::to_value(old_rule).unwrap() + ); + reviews.extend(perfect_day(&reviews, 3, &utc())); + let earned = run(&reviews, at(4), &policy); + assert_eq!(earned.stored_freezes, 1); + let off = FreezePolicy { + preferences: vec![protection(at(1), false)], + ..policy.clone() + }; + assert!(!run(&reviews, at(4), &off).freezes_enabled); + assert_eq!(run(&reviews, at(4), &off).stored_freezes, 0); + let opted_in = FreezePolicy { + preferences: vec![protection(0, true)], + ..policy + }; + assert_eq!(run(&reviews, at(4), &opted_in).stored_freezes, 3); + } + + #[test] + fn explicitly_disabled_freezes_start_empty_without_historical_rewards() { let reviews = perfect_history(&[1, 2, 3, 4, 5, 6, 7]); - let p = protected(&reviews, at(9), &[]); + let p = protected(&reviews, at(9), &[protection(0, false)]); assert!(!p.freezes_enabled); assert_eq!(p.stored_freezes, 0); assert_eq!(p.freezes, 0); assert_eq!(p.streak, 0); assert!(p.heatmap.iter().all(|c| !c.frozen)); - let opted_in = protected(&reviews, at(9), &[protection(at(9), true)]); + let opted_in = protected( + &reviews, + at(9), + &[protection(0, false), protection(at(9), true)], + ); assert!(opted_in.freezes_enabled); assert_eq!(opted_in.freezes, 0); assert!(!opted_in.freeze_earned_today); @@ -1757,6 +1833,7 @@ mod tests { let policy = FreezePolicy { legacy_until: Some(at(10)), preferences: vec![], + default_enabled_from: Some(at(10)), }; let p = compute_with_freezes( "a", @@ -1779,7 +1856,7 @@ mod tests { ); assert_eq!(p.freezes, 0); assert_eq!(p.stored_freezes, 0); - assert!(!p.freezes_enabled); + assert!(p.freezes_enabled); let before_cutover = compute_with_freezes( "a", "a", @@ -1790,6 +1867,7 @@ mod tests { &FreezePolicy { legacy_until: Some(at(10)), preferences: vec![], + default_enabled_from: Some(at(10)), }, ); assert_eq!(p.xp_total, before_cutover.xp_total); @@ -1813,6 +1891,7 @@ mod tests { let policy = FreezePolicy { legacy_until: Some(utc().day_start_ms(15)), preferences: vec![protection(utc().day_start_ms(15), true)], + ..FreezePolicy::default() }; let run = |reviews: &[Review], day| { compute_with_freezes( @@ -1895,12 +1974,22 @@ mod tests { .any(|quest| !quest.done) ); assert_eq!( - protected(&reviews, at(2), &[protection(completion, true)]).stored_freezes, + protected( + &reviews, + at(2), + &[protection(0, false), protection(completion, true)] + ) + .stored_freezes, 1, "opting in at the visible completion earns today's freeze" ); assert_eq!( - protected(&reviews, at(2), &[protection(completion + 1, true)]).stored_freezes, + protected( + &reviews, + at(2), + &[protection(0, false), protection(completion + 1, true)] + ) + .stored_freezes, 0, "opting in after completion cannot claim a past reward" ); @@ -1911,11 +2000,21 @@ mod tests { let mut reviews = perfect_history(&[1]); let completion = first_completion(&reviews, 1); assert_eq!( - protected(&reviews, at(1), &[protection(completion, true)]).freezes, + protected( + &reviews, + at(1), + &[protection(0, false), protection(completion, true)] + ) + .freezes, 1 ); assert_eq!( - protected(&reviews, at(1), &[protection(completion + 1, true)]).freezes, + protected( + &reviews, + at(1), + &[protection(0, false), protection(completion + 1, true)] + ) + .freezes, 0 ); reviews.extend(reviews_on(1, 3, 999)); @@ -2079,7 +2178,11 @@ mod tests { assert_eq!(before.freezes, 0); assert!(!before.freeze_earned_today); assert_eq!(protected(&reviews, completion, &changes).freezes, 1); - let future = protected(&reviews, at(1), &[protection(at(2), true)]); + let future = protected( + &reviews, + at(1), + &[protection(0, false), protection(at(2), true)], + ); assert!(!future.freezes_enabled); assert_eq!(future.stored_freezes, 0); assert_eq!( diff --git a/src/main.rs b/src/main.rs index 3bf7922..bd609c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -251,10 +251,10 @@ struct FreezeUpdate { } fn freeze_settings(app: &App, store: &Store, user: &str) -> Result { - let history = store.freeze_preferences(user)?; + let policy = store.freeze_policy(user)?; let profile = app.profile(user); Ok(FreezeSettings { - enabled: history.last().is_some_and(|change| change.enabled), + enabled: policy.enabled_at(now_ms()), freezes: profile.map_or(0, |profile| profile.stored_freezes), capacity: game::MAX_FREEZES, }) @@ -2670,7 +2670,7 @@ mod tests { } #[tokio::test] - async fn streak_freezes_start_empty_can_be_enabled_before_upload_and_reload() { + async fn streak_freezes_default_on_and_first_opt_out_survives_restart() { let (app, path) = fixture(); let settings = get_freezes( State(app.clone()), @@ -2680,7 +2680,39 @@ mod tests { .await .unwrap() .0; - assert!(!settings.enabled); + assert!(settings.enabled); + assert_eq!((settings.freezes, settings.capacity), (0, 3)); + let disabled = set_freezes( + State(app.clone()), + UrlPath("cerro".into()), + headers("cerro"), + Json(FreezeUpdate { enabled: false }), + ) + .await + .unwrap() + .0; + assert!(!disabled.enabled); + assert!(app.players.read().unwrap().is_empty()); + drop(app); + let store = Store::open(&path).unwrap(); + assert_eq!(store.freeze_preferences("cerro").unwrap().len(), 1); + assert!(!store.freeze_preferences("cerro").unwrap()[0].enabled); + drop(store); + std::fs::remove_dir_all(path).unwrap(); + } + + #[tokio::test] + async fn streak_freezes_start_empty_can_be_toggled_before_upload_and_reload() { + let (app, path) = fixture(); + let settings = get_freezes( + State(app.clone()), + UrlPath("cerro".into()), + headers("cerro"), + ) + .await + .unwrap() + .0; + assert!(settings.enabled); assert_eq!((settings.freezes, settings.capacity), (0, 3)); for enabled in [true, true, false, true] { let settings = set_freezes( @@ -2703,13 +2735,13 @@ mod tests { .await .unwrap() .0; - assert!(!other.enabled); + assert!(other.enabled); drop(app); let store = Store::open(&path).unwrap(); let history = store.freeze_preferences("cerro").unwrap(); assert_eq!( history.len(), - 3, + 2, "repeated saves cannot change the activation time" ); assert!(history.last().unwrap().enabled); @@ -2718,7 +2750,7 @@ mod tests { } #[test] - fn streak_freeze_updates_cannot_set_a_balance_or_omit_opt_in() { + fn streak_freeze_updates_cannot_set_a_balance_or_omit_enabled() { for value in [ serde_json::json!({}), serde_json::json!({"enabled": null}), diff --git a/src/reminders.rs b/src/reminders.rs index ab4a80f..8933677 100644 --- a/src/reminders.rs +++ b/src/reminders.rs @@ -1246,6 +1246,7 @@ mod tests { let (mut store, path) = store(); let clock = Clock::default(); let mut p = profile(&clock, at(20)); + p.freezes_enabled = false; save_settings( &mut store, "hill", diff --git a/static/index.html b/static/index.html index 4683827..631c3d8 100644 --- a/static/index.html +++ b/static/index.html @@ -1101,11 +1101,11 @@

Deck completion notifications

Choose who hears when you f let token = "", session = null, closed = false, busy = false; let resetAuth = () => {}; dialog.innerHTML = aqHtml`

Streak protection

-

A little backup for ${esc(display)}'s study streak. Off by default.

+

A little backup for ${esc(display)}'s study streak. On by default.

    -
  • Earn your ice. Start with 0 freezes. After opting in, finish all three daily quests to earn one per Anki day. Hold up to 3.
  • +
  • Earn your ice. Start with 0 freezes. Finish all three daily quests while protection is on to earn one per Anki day. Hold up to 3.
  • Keep your streak. While protection is on, one freeze is used automatically for each ended Anki day with no reviews.
  • -
  • Pause whenever you like. Turning it off pauses earning and use; saved freezes stay. Quests completed before you opt in do not earn freezes.
  • +
  • Pause whenever you like. Turning it off pauses earning and use; saved freezes stay. Quests completed while protection is off do not earn freezes.
diff --git a/static/translations-es.json b/static/translations-es.json index 8d5451a..2264e39 100644 --- a/static/translations-es.json +++ b/static/translations-es.json @@ -15,7 +15,7 @@ "A friendly nudge when you haven’t studied yet.": "Un toque amistoso cuando aún no hayas estudiado.", "A lifetime of learning": "Toda una vida aprendiendo", "A little backup for your streak": "Un respaldo para tu racha", - "A little backup for {0}'s study streak. Off by default.": "Un respaldo para la racha de {0}. Desactivado inicialmente.", + "A little backup for {0}'s study streak. On by default.": "Un respaldo para la racha de {0}. Activado inicialmente.", "A little encouragement": "Un poco de ánimo", "A little encouragement before your next streak milestone.": "Un poco de ánimo antes del próximo hito de tu racha.", "A little encouragement to do Anki. One nudge per friend per Anki day.": "Un poco de ánimo para hacer Anki. Un toque por amigo y por día de Anki.", @@ -553,7 +553,7 @@ "Speed Demon": "Velocidad endiablada", "Start a friend goal": "Crear un objetivo con amigos", "Start a friendly challenge": "Crear un reto amistoso", - "Start with 0 freezes. After opting in, finish all three daily quests to earn one per Anki day. Hold up to 3.": "Empiezas con 0 protectores. Al activarlos, completa las tres misiones diarias para conseguir uno por día de Anki. Puedes guardar hasta 3.", + "Start with 0 freezes. Finish all three daily quests while protection is on to earn one per Anki day. Hold up to 3.": "Empiezas con 0 protectores. Completa las tres misiones diarias mientras la protección esté activada para conseguir uno por día de Anki. Puedes guardar hasta 3.", "Status": "Estado", "Still ahead": "Todavía por delante", "Streak freezes": "Protectores de racha", @@ -658,7 +658,7 @@ "Tue": "Mar", "Turn off to stop all deck completion alerts.": "Desactívalo para detener todos los avisos de mazos completados.", "Turning all alerts off cancels pending deck completion alerts without changing your subscriptions.": "Desactivar todos los avisos cancela los pendientes de mazos completados sin cambiar tus suscripciones.", - "Turning it off pauses earning and use; saved freezes stay. Quests completed before you opt in do not earn freezes.": "Desactivarlo pausa la obtención y el uso; conservas los protectores guardados. Las misiones completadas antes de activarlo no dan protectores.", + "Turning it off pauses earning and use; saved freezes stay. Quests completed while protection is off do not earn freezes.": "Desactivarlo pausa la obtención y el uso; conservas los protectores guardados. Las misiones completadas mientras la protección esté desactivada no dan protectores.", "Two Orbits": "Dos órbitas", "Two Thousand Days Studied": "Dos mil días estudiados", "Two Thousand Hours": "Dos mil horas", diff --git a/tests/test_settings_auth.cjs b/tests/test_settings_auth.cjs index 381552b..7f9834e 100644 --- a/tests/test_settings_auth.cjs +++ b/tests/test_settings_auth.cjs @@ -163,6 +163,28 @@ async function fixture(t, session = savedSession, user = 'cerro', options = {}) }; } +test('freezes: enabled-by-default protection can be opted out without losing stock', async t => { + const f = await fixture(t); + const spec = dialogs.find(dialog => dialog.name === 'freezes'); + await f.page.route(`${origin}/api/streak-freezes/**`, route => route.fulfill({ + contentType: 'application/json', body: JSON.stringify({ enabled: true, freezes: 2, capacity: 3 }), + }), { times: 1 }); + await f.open(spec); + await f.ready(spec); + const toggle = f.page.locator('#freeze-enabled'); + assert.equal(await toggle.isChecked(), true); + await toggle.uncheck(); + await f.page.locator(spec.ready).getByRole('button', { name: spec.save, exact: true }).click(); + await f.page.locator('#freeze-status').filter({ hasText: /is off/i }).waitFor(); + const saved = f.settingsRequests().find(request => request.method === 'POST'); + assert.deepEqual(JSON.parse(saved.body), { enabled: false }); + assert.match(await f.page.locator('.freeze-balance').innerText(), /2 of 3 freezes saved/i); + await f.close(spec); + await f.open(spec); + await f.ready(spec); + assert.equal(await f.page.locator('#freeze-enabled').isChecked(), false); +}); + test('decks: an unexpected response can be retried without reopening', async t => { const f = await fixture(t); const spec = dialogs.find(dialog => dialog.name === 'decks');