From 433cc36bb2460e3e6b82738d3d7e3e81faf630a3 Mon Sep 17 00:00:00 2001 From: Fernando Lins <1887601+fernandolins@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:28:32 -0300 Subject: [PATCH 1/6] test(e2e): add editor chooser and RPC helpers --- ts/tests/e2e/helpers.ts | 62 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/ts/tests/e2e/helpers.ts b/ts/tests/e2e/helpers.ts index 621964fbd7b..17ad3fb30a0 100644 --- a/ts/tests/e2e/helpers.ts +++ b/ts/tests/e2e/helpers.ts @@ -1,7 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -import type { Locator, Page, Request } from "@playwright/test"; +import type { Locator, Page, Request, Response } from "@playwright/test"; // --------------------------------------------------------------------------- // RPC URL helpers @@ -21,6 +21,10 @@ export function isRpc(method: string): (req: Request) => boolean { return (req) => req.url().endsWith(suffix); } +export function isRpcResponse(method: string): (resp: Response) => boolean { + return (resp) => isRpc(method)(resp.request()); +} + // --------------------------------------------------------------------------- // Field locators // @@ -47,6 +51,30 @@ export function editableField(page: Page, index: number): Locator { .locator("anki-editable[contenteditable='true']"); } +// --------------------------------------------------------------------------- +// Chooser helpers +// --------------------------------------------------------------------------- + +export function chooserButton(page: Page, kind: "notetype" | "deck"): Locator { + return page.locator("button.chooser-button").nth(kind === "notetype" ? 0 : 1); +} + +export async function openChooserAndSelect( + page: Page, + kind: "notetype" | "deck", + itemName: string, +): Promise { + await chooserButton(page, kind).click(); + const modal = page.locator(".modal.show"); + await modal.waitFor({ state: "visible", timeout: 5_000 }); + await modal.getByRole("button", { name: `Select ${itemName}` }).click(); + await modal.waitFor({ state: "hidden", timeout: 5_000 }); + await chooserButton(page, kind).filter({ hasText: itemName }).waitFor({ + state: "visible", + timeout: 5_000, + }); +} + // --------------------------------------------------------------------------- // Bridge call inspection // --------------------------------------------------------------------------- @@ -69,6 +97,10 @@ type BinaryDecodable = { fromBinary(bytes: Uint8Array): T; }; +type BinaryEncodable = { + toBinary(): Uint8Array; +}; + export function decodeRequestBody( request: Request, messageType: BinaryDecodable, @@ -84,6 +116,34 @@ export function decodeRequestBody( } } +export async function callRpc( + page: Page, + method: string, + message: BinaryEncodable, + opChangesType = 0, +): Promise { + const responseBytes = await page.evaluate( + async ({ method, body, opChangesType }) => { + const response = await fetch(`/_anki/${method}`, { + method: "POST", + headers: { + "Content-Type": "application/binary", + "Anki-Op-Changes": opChangesType.toString(), + }, + body: new Uint8Array(body), + }); + if (!response.ok) { + throw new Error( + `RPC ${method} failed with ${response.status}: ${await response.text()}`, + ); + } + return Array.from(new Uint8Array(await response.arrayBuffer())); + }, + { method, body: Array.from(message.toBinary()), opChangesType }, + ); + return new Uint8Array(responseBytes); +} + // --------------------------------------------------------------------------- // Paste helper // From 940f87b68bd96340d52753764159983a8c80933d Mon Sep 17 00:00:00 2001 From: Fernando Lins <1887601+fernandolins@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:42:23 -0300 Subject: [PATCH 2/6] test(e2e): cover editor add context switching parity --- ts/tests/e2e/context-switching.spec.ts | 271 +++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 ts/tests/e2e/context-switching.spec.ts diff --git a/ts/tests/e2e/context-switching.spec.ts b/ts/tests/e2e/context-switching.spec.ts new file mode 100644 index 00000000000..e89f860e2d8 --- /dev/null +++ b/ts/tests/e2e/context-switching.spec.ts @@ -0,0 +1,271 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +/** + * Context switching coverage for issue #4948. + * + * These tests exercise the Svelte editor's add-mode deck/notetype choosers + * end-to-end: chooser UI, reload-on-notetype-change, addNote payloads, and the + * backend default-context persistence that is written only after a successful + * add. + */ + +import { DeckNames, GetDeckNamesRequest } from "@generated/anki/decks_pb"; +import { Empty, String as GenericString } from "@generated/anki/generic_pb"; +import { AddNoteRequest } from "@generated/anki/notes_pb"; +import { NotetypeNames } from "@generated/anki/notetypes_pb"; + +import { expect, test } from "./fixtures"; +import { + callRpc, + chooserButton, + decodeRequestBody, + editableField, + fieldContainer, + isRpc, + isRpcResponse, + openChooserAndSelect, +} from "./helpers"; + +const TEST_DECK_NAME = `Context Switching ${Date.now()}`; +const DEFAULT_DECK_ID = 1n; + +let testDeckId: bigint | null = null; +let basicNotetypeId: bigint | null = null; +let clozeNotetypeId: bigint | null = null; + +async function decodedRpc( + page, + method: string, + message, + responseType: { fromBinary(bytes: Uint8Array): T }, + opChangesType = 0, +): Promise { + return responseType.fromBinary(await callRpc(page, method, message, opChangesType)); +} + +async function ensureContextFixtures(page): Promise { + if (testDeckId !== null && basicNotetypeId !== null && clozeNotetypeId !== null) { + return; + } + + const notetypeNames = await decodedRpc( + page, + "getNotetypeNames", + new Empty(), + NotetypeNames, + ); + basicNotetypeId = notetypeNames.entries.find((entry) => entry.name === "Basic") + ?.id ?? null; + clozeNotetypeId = notetypeNames.entries.find((entry) => entry.name === "Cloze") + ?.id ?? null; + + if (basicNotetypeId === null || clozeNotetypeId === null) { + throw new Error("Expected stock Basic and Cloze notetypes in e2e profile"); + } + + await callRpc( + page, + "importJsonString", + new GenericString({ + val: JSON.stringify({ + default_deck: TEST_DECK_NAME, + notes: [], + }), + }), + 2, + ); + + const deckNames = await decodedRpc( + page, + "getDeckNames", + new GetDeckNamesRequest(), + DeckNames, + ); + testDeckId = deckNames.entries.find((entry) => entry.name === TEST_DECK_NAME)?.id + ?? null; + if (testDeckId === null) { + throw new Error(`Expected imported test deck "${TEST_DECK_NAME}"`); + } +} + +async function loadEditorInitial(page): Promise { + await page.waitForFunction( + () => typeof (window as any).loadNote === "function", + { timeout: 15_000 }, + ); + await page.evaluate(() => (window as any).loadNote({ initial: true })); + await page.waitForSelector(".field-container", { timeout: 15_000 }); +} + +async function reloadEditorAfterSetup(page): Promise { + await page.reload({ waitUntil: "domcontentloaded" }); + await page.waitForSelector(".note-editor", { timeout: 15_000 }); + await loadEditorInitial(page); +} + +async function loadSpecificContext( + page, + notetypeId: bigint, + deckId: bigint, +): Promise { + await page.evaluate( + ({ notetypeId, deckId }) => + (window as any).loadNote({ + notetypeId: BigInt(notetypeId), + deckId: BigInt(deckId), + }), + { notetypeId: notetypeId.toString(), deckId: deckId.toString() }, + ); + await page.waitForSelector(".field-container", { timeout: 15_000 }); +} + +async function fillAndAdd(page, fieldText: string): Promise { + const field = editableField(page, 0); + await field.click(); + await field.pressSequentially(fieldText); + + const addNoteReqPromise = page.waitForRequest(isRpc("addNote"), { + timeout: 10_000, + }); + await page.getByRole("button", { name: "Add", exact: true }).click(); + const addNoteReq = await addNoteReqPromise; + await page.waitForResponse( + (resp) => isRpcResponse("addNote")(resp) && resp.status() < 400, + { timeout: 10_000 }, + ); + + return decodeRequestBody(addNoteReq, AddNoteRequest); +} + +async function restoreBasicDefault(page): Promise { + await loadSpecificContext(page, basicNotetypeId!, DEFAULT_DECK_ID); + await fillAndAdd(page, "Restore default context"); +} + +test.beforeEach(async ({ editor: page }) => { + await ensureContextFixtures(page); + await reloadEditorAfterSetup(page); +}); + +test("switching notetype updates the editor fields through the Svelte path", async ({ editor: page }) => { + const getNotetypeReqPromise = page.waitForRequest(isRpc("getNotetype"), { + timeout: 10_000, + }); + const newNoteReqPromise = page.waitForRequest(isRpc("newNote"), { + timeout: 10_000, + }); + + await openChooserAndSelect(page, "notetype", "Cloze"); + + await getNotetypeReqPromise; + await newNoteReqPromise; + + await expect(chooserButton(page, "notetype")).toHaveText("Cloze"); + await expect(fieldContainer(page, 0).getByText("Text", { exact: true })) + .toBeVisible(); + await expect( + fieldContainer(page, 1).getByText("Back Extra", { exact: true }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Front", exact: true })) + .toHaveCount(0); + await expect(page.getByRole("button", { name: "Back", exact: true })) + .toHaveCount(0); +}); + +test("selected notetype persists as context for the next note after add", async ({ editor: page }) => { + // Criterion 1 (issue #4948): assert notetype persists on the next opened note. + // Tested in isolation so any regression in notetype-only persistence is + // visible independently of the combined notetype+deck scenario below. + try { + await openChooserAndSelect(page, "notetype", "Cloze"); + + // Set up newNote listener before the add so we don't race against the + // post-add reload that fires it. + const newNotePromise = page.waitForRequest(isRpc("newNote"), { timeout: 10_000 }); + await fillAndAdd(page, "{{c1::notetype persist}}"); + await newNotePromise; + + const defaultsReqPromise = page.waitForRequest(isRpc("defaultsForAdding"), { + timeout: 10_000, + }); + await page.evaluate(() => (window as any).loadNote({ initial: true })); + await defaultsReqPromise; + + await expect(chooserButton(page, "notetype")).toHaveText("Cloze"); + } finally { + await restoreBasicDefault(page); + } +}); + +test("switching deck sends addNote to the selected deck", async ({ editor: page }) => { + await openChooserAndSelect(page, "deck", TEST_DECK_NAME); + await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); + + const decoded = await fillAndAdd(page, "Deck switch payload"); + + expect(decoded.deckId).toBe(testDeckId); + expect(decoded.note?.notetypeId).toBe(basicNotetypeId); +}); + +test("switching deck and notetype sends addNote with both selected ids", async ({ editor: page }) => { + // Criterion 3 (issue #4948): both choices persist correctly in the same session. + // "Same session" means the choosers still reflect the selection immediately + // after the add — before any explicit reopen. + try { + await openChooserAndSelect(page, "notetype", "Cloze"); + await openChooserAndSelect(page, "deck", TEST_DECK_NAME); + + const newNotePromise = page.waitForRequest(isRpc("newNote"), { timeout: 10_000 }); + const decoded = await fillAndAdd(page, "{{c1::context answer}}"); + + // Payload must carry both selected ids. + expect(decoded.deckId).toBe(testDeckId); + expect(decoded.note?.notetypeId).toBe(clozeNotetypeId); + + // Wait for the post-add reload to settle before checking chooser state. + await newNotePromise; + + // Within-session context: both choosers must still reflect the selection + // immediately after the add (before any reopen). + await expect(chooserButton(page, "notetype")).toHaveText("Cloze"); + await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); + } finally { + await restoreBasicDefault(page); + } +}); + +test("reopening add mode remembers the last added deck and notetype context", async ({ editor: page }) => { + // Criterion 4 (issue #4948): assert the last used notetype and deck are + // pre-selected when the editor is reopened. + try { + await openChooserAndSelect(page, "notetype", "Cloze"); + await openChooserAndSelect(page, "deck", TEST_DECK_NAME); + + const newNotePromise = page.waitForRequest(isRpc("newNote"), { timeout: 10_000 }); + await fillAndAdd(page, "{{c1::remembered context}}"); + await newNotePromise; + + // Intermediate check: context is intact immediately after the add, + // before any explicit reopen. + await expect(chooserButton(page, "notetype")).toHaveText("Cloze"); + await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); + + // Reopen check: simulate re-entering Add mode (e.g. closing and + // reopening the Add Cards window). + const defaultsReqPromise = page.waitForRequest(isRpc("defaultsForAdding"), { + timeout: 10_000, + }); + await page.evaluate(() => (window as any).loadNote({ initial: true })); + await defaultsReqPromise; + + // Both must be remembered after reopen (FIX REQUIRED in PR #4029). + // Using soft assertions so both chooser states are reported even when + // the first one fails — the combined notetype+deck reopen scenario is + // known to not restore context correctly. + await expect.soft(chooserButton(page, "notetype")).toHaveText("Cloze"); + await expect.soft(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); + } finally { + await restoreBasicDefault(page); + } +}); From 1067df81ad845873f81414e6233625a4f3e6731f Mon Sep 17 00:00:00 2001 From: Fernando Lins <1887601+fernandolins@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:47:07 -0300 Subject: [PATCH 3/6] test(e2e): expand context-switching coverage to address reviewer feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename "notetype persists" test to "notetype and deck persist" and switch the add to use TEST_DECK instead of Default so the assertion exercises the real _nt_{ntid}_lastDeck persistence path rather than the trivially-satisfied Default fallback. - Add setConfigJson helper and force Mode B (addToCur=false) in beforeEach: fresh Anki collections default to Mode A (addToCur=true per schema11), which caused all Mode B assertions to silently pass or fail against the wrong code path. - Add test: mode B session — switching notetype auto-selects the last deck used with that notetype (onNotetypeChange → defaultDeckForNotetype → deckChooser.select, no user interaction required). - Add test: mode A reopen — last notetype used for the current deck is restored via _deck_{did}_lastNotetype. - Add test: mode B fallback (no history) — when _nt_{ntid}_lastDeck is absent or points to a deleted deck, defaults_for_adding falls back to the collection's curDeck, not Default. - Add test: mode A fallback — when _deck_{did}_lastNotetype is absent, default_notetype_for_deck falls back to get_current_notetype_for_adding. All branches of rslib/src/adding.rs are now covered. --- ts/tests/e2e/context-switching.spec.ts | 178 ++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 6 deletions(-) diff --git a/ts/tests/e2e/context-switching.spec.ts b/ts/tests/e2e/context-switching.spec.ts index e89f860e2d8..21bf78fa885 100644 --- a/ts/tests/e2e/context-switching.spec.ts +++ b/ts/tests/e2e/context-switching.spec.ts @@ -10,6 +10,7 @@ * add. */ +import { SetConfigJsonRequest } from "@generated/anki/config_pb"; import { DeckNames, GetDeckNamesRequest } from "@generated/anki/decks_pb"; import { Empty, String as GenericString } from "@generated/anki/generic_pb"; import { AddNoteRequest } from "@generated/anki/notes_pb"; @@ -143,8 +144,24 @@ async function restoreBasicDefault(page): Promise { await fillAndAdd(page, "Restore default context"); } +/** Write an arbitrary value to a collection config key using the setConfigJson RPC. */ +async function setConfigJson(page, key: string, value: unknown): Promise { + await callRpc( + page, + "setConfigJson", + new SetConfigJsonRequest({ + key, + valueJson: new TextEncoder().encode(JSON.stringify(value)), + }), + ); +} + test.beforeEach(async ({ editor: page }) => { await ensureContextFixtures(page); + // A fresh collection defaults to Mode A (addToCur = true per schema11). + // Force Mode B so all tests share a known baseline; Mode A tests + // re-enable it themselves and restore it in their finally block. + await setConfigJson(page, "addToCur", false); await reloadEditorAfterSetup(page); }); @@ -173,15 +190,15 @@ test("switching notetype updates the editor fields through the Svelte path", asy .toHaveCount(0); }); -test("selected notetype persists as context for the next note after add", async ({ editor: page }) => { - // Criterion 1 (issue #4948): assert notetype persists on the next opened note. - // Tested in isolation so any regression in notetype-only persistence is - // visible independently of the combined notetype+deck scenario below. +test("selected notetype and deck persist as context for the next note after add", async ({ editor: page }) => { + // Criterion 1 (issue #4948): assert notetype and deck persist on reopen. + // Using a non-default deck so the test exercises the real + // _nt_{ntid}_lastDeck persistence path rather than the fallback to the + // Default deck that would pass even with broken persistence logic. try { await openChooserAndSelect(page, "notetype", "Cloze"); + await openChooserAndSelect(page, "deck", TEST_DECK_NAME); - // Set up newNote listener before the add so we don't race against the - // post-add reload that fires it. const newNotePromise = page.waitForRequest(isRpc("newNote"), { timeout: 10_000 }); await fillAndAdd(page, "{{c1::notetype persist}}"); await newNotePromise; @@ -192,7 +209,10 @@ test("selected notetype persists as context for the next note after add", async await page.evaluate(() => (window as any).loadNote({ initial: true })); await defaultsReqPromise; + // currentNotetypeId = Cloze; _nt_{cloze}_lastDeck = testDeckId. + // Both must be restored through real persistence, not the Default fallback. await expect(chooserButton(page, "notetype")).toHaveText("Cloze"); + await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); } finally { await restoreBasicDefault(page); } @@ -235,6 +255,38 @@ test("switching deck and notetype sends addNote with both selected ids", async ( } }); +test("mode B: switching notetype auto-selects the last deck used with that notetype", async ({ editor: page }) => { + // Adding.rs Mode B: each notetype remembers the last deck it was added to. + // When the user switches notetype via the chooser, onNotetypeChange calls + // defaultDeckForNotetype({ ntid }) and auto-selects the deck via + // deckChooser.select — without the user touching the deck chooser at all. + try { + // Step 1: Establish _nt_{cloze}_lastDeck = testDeckId. + await openChooserAndSelect(page, "notetype", "Cloze"); + await openChooserAndSelect(page, "deck", TEST_DECK_NAME); + const newNote1 = page.waitForRequest(isRpc("newNote"), { timeout: 10_000 }); + await fillAndAdd(page, "{{c1::deck mapping}}"); + await newNote1; + + // Step 2: Reset to Basic + Default so the starting state is deterministic. + await restoreBasicDefault(page); + await reloadEditorAfterSetup(page); + await expect(chooserButton(page, "notetype")).toHaveText("Basic"); + await expect(chooserButton(page, "deck")).toHaveText("Default"); + + // Step 3: Switch to Cloze. NoteEditor reads _nt_{cloze}_lastDeck and + // auto-calls deckChooser.select(testDeckId) without user interaction. + await openChooserAndSelect(page, "notetype", "Cloze"); + + // Step 4: Deck chooser must update on its own. + await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME, { + timeout: 8_000, + }); + } finally { + await restoreBasicDefault(page); + } +}); + test("reopening add mode remembers the last added deck and notetype context", async ({ editor: page }) => { // Criterion 4 (issue #4948): assert the last used notetype and deck are // pre-selected when the editor is reopened. @@ -269,3 +321,117 @@ test("reopening add mode remembers the last added deck and notetype context", as await restoreBasicDefault(page); } }); + +test("mode A: last notetype used for the current deck is restored on reopen", async ({ editor: page }) => { + // Adding.rs Mode A (AddingDefaultsToCurrentDeck = true): the deck is the + // collection's current deck, and the notetype is the last one used with + // that deck (_deck_{did}_lastNotetype). + // Contrasts with Mode B where the notetype drives the deck selection. + try { + // Re-enable Mode A (beforeEach forced Mode B). + await setConfigJson(page, "addToCur", true); + + // In Mode A, switching notetype does NOT auto-update the deck chooser + // (the onNotetypeChange guard skips defaultDeckForNotetype when mode is A). + await openChooserAndSelect(page, "notetype", "Cloze"); + await expect(chooserButton(page, "deck")).toHaveText("Default"); + + // Add Cloze + Default → writes _deck_{Default}_lastNotetype = Cloze. + const newNotePromise = page.waitForRequest(isRpc("newNote"), { timeout: 10_000 }); + await fillAndAdd(page, "{{c1::mode A test}}"); + await newNotePromise; + + // Simulate reopen. + const defaultsReqPromise = page.waitForRequest(isRpc("defaultsForAdding"), { + timeout: 10_000, + }); + await page.evaluate(() => (window as any).loadNote({ initial: true })); + await defaultsReqPromise; + + // Mode A: deck = current deck (Default), notetype = _deck_{Default}_lastNotetype = Cloze. + await expect(chooserButton(page, "deck")).toHaveText("Default"); + await expect(chooserButton(page, "notetype")).toHaveText("Cloze"); + } finally { + // Restore Mode B so the next test's beforeEach starts cleanly. + await setConfigJson(page, "addToCur", false); + await restoreBasicDefault(page); + } +}); + +test("mode B: notetype with no lastDeck falls back to the collection's current deck", async ({ editor: page }) => { + // adding.rs Mode B fallback path: when _nt_{ntid}_lastDeck is absent, + // default_deck_for_notetype returns None and defaults_for_adding falls back to + // get_current_deck_for_adding(), which returns the collection's current deck. + // The test changes curDeck so that a broken fallback (e.g. always returning + // Default) is detectable. + try { + // Point _nt_{basicNotetypeId}_lastDeck at a non-existent deck — this has + // the same effect as the key being absent: get_deck() returns None and + // default_deck_for_notetype returns None, triggering the curDeck fallback. + await setConfigJson(page, `_nt_${basicNotetypeId}_lastDeck`, 999_999_999); + // Set the collection's current deck (curDeck) to the non-Default testDeck. + await setConfigJson(page, "curDeck", Number(testDeckId)); + + const defaultsReqPromise = page.waitForRequest(isRpc("defaultsForAdding"), { + timeout: 10_000, + }); + await page.evaluate(() => (window as any).loadNote({ initial: true })); + await defaultsReqPromise; + + // Deck must be testDeck (the current deck), not Default — confirming the + // fallback path reads get_current_deck_for_adding() correctly. + await expect(chooserButton(page, "notetype")).toHaveText("Basic"); + await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); + } finally { + await setConfigJson(page, "curDeck", 1); + await restoreBasicDefault(page); + } +}); + +test("mode B: deleted lastDeck is ignored and current deck is used instead", async ({ editor: page }) => { + // adding.rs: default_deck_for_notetype calls get_deck(last_deck_id) and skips + // if None (deck was deleted). The fallback must produce curDeck, not Default. + try { + await setConfigJson(page, `_nt_${basicNotetypeId}_lastDeck`, 999_999_999); + await setConfigJson(page, "curDeck", Number(testDeckId)); + + const defaultsReqPromise = page.waitForRequest(isRpc("defaultsForAdding"), { + timeout: 10_000, + }); + await page.evaluate(() => (window as any).loadNote({ initial: true })); + await defaultsReqPromise; + + await expect(chooserButton(page, "notetype")).toHaveText("Basic"); + await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); + } finally { + await setConfigJson(page, "curDeck", 1); + await restoreBasicDefault(page); + } +}); + +test("mode A: deck with no lastNotetype falls back to the global current notetype", async ({ editor: page }) => { + // adding.rs Mode A fallback: when _deck_{did}_lastNotetype is absent, + // default_notetype_for_deck falls back to get_current_notetype_for_adding(), + // which returns the global current notetype. restoreBasicDefault() (run by + // previous tests and in finally blocks) leaves that global notetype as Basic. + try { + await setConfigJson(page, "addToCur", true); + // NotetypeId 0 does not exist → get_notetype(0) returns None → fallback. + await setConfigJson(page, `_deck_${testDeckId}_lastNotetype`, 0); + await setConfigJson(page, "curDeck", Number(testDeckId)); + + const defaultsReqPromise = page.waitForRequest(isRpc("defaultsForAdding"), { + timeout: 10_000, + }); + await page.evaluate(() => (window as any).loadNote({ initial: true })); + await defaultsReqPromise; + + // Mode A: deck = testDeck (curDeck), notetype = Basic (global current notetype fallback). + await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); + await expect(chooserButton(page, "notetype")).toHaveText("Basic"); + } finally { + await setConfigJson(page, "addToCur", false); + await setConfigJson(page, "curDeck", 1); + await restoreBasicDefault(page); + } +}); From e1b0bfb9f7bf6dbf2f51f3255f483398eb95e408 Mon Sep 17 00:00:00 2001 From: Fernando Lins <1887601+fernandolins@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:48:13 -0300 Subject: [PATCH 4/6] chore(mediasrv): expose config and deck management RPCs Add set_config_bool, set_config_json_no_undo, remove_config and set_current_deck to the exposed_backend_list so they are reachable via /_anki/{method} from the web layer. These RPCs are already defined in the proto and have _raw variants in the backend; exposing them is consistent with the existing set_config_json and get_config_bool entries. --- qt/aqt/mediasrv.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index 01c260821c5..313d032ec22 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -1025,6 +1025,7 @@ def save_custom_colours() -> bytes: # DeckService "get_deck_names", "get_deck", + "set_current_deck", # I18nService "i18n_resources", # ImportExportService @@ -1080,7 +1081,10 @@ def save_custom_colours() -> bytes: "html_to_text_line", # ConfigService "set_config_json", + "set_config_json_no_undo", + "set_config_bool", "get_config_bool", + "remove_config", # MediaService "add_media_file", "add_media_from_path", From 5428a2048eab1fef20e6199902563a808be64241 Mon Sep 17 00:00:00 2001 From: Fernando Lins <1887601+fernandolins@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:07:34 -0300 Subject: [PATCH 5/6] Fixes lint issue --- ts/tests/e2e/context-switching.spec.ts | 56 ++++++++++++++------------ 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/ts/tests/e2e/context-switching.spec.ts b/ts/tests/e2e/context-switching.spec.ts index 21bf78fa885..8441d0a5f17 100644 --- a/ts/tests/e2e/context-switching.spec.ts +++ b/ts/tests/e2e/context-switching.spec.ts @@ -2,8 +2,7 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /** - * Context switching coverage for issue #4948. - * + * Context switching coverage. * These tests exercise the Svelte editor's add-mode deck/notetype choosers * end-to-end: chooser UI, reload-on-notetype-change, addNote payloads, and the * backend default-context persistence that is written only after a successful @@ -56,10 +55,8 @@ async function ensureContextFixtures(page): Promise { new Empty(), NotetypeNames, ); - basicNotetypeId = notetypeNames.entries.find((entry) => entry.name === "Basic") - ?.id ?? null; - clozeNotetypeId = notetypeNames.entries.find((entry) => entry.name === "Cloze") - ?.id ?? null; + basicNotetypeId = notetypeNames.entries.find((entry) => entry.name === "Basic")?.id ?? null; + clozeNotetypeId = notetypeNames.entries.find((entry) => entry.name === "Cloze")?.id ?? null; if (basicNotetypeId === null || clozeNotetypeId === null) { throw new Error("Expected stock Basic and Cloze notetypes in e2e profile"); @@ -83,18 +80,16 @@ async function ensureContextFixtures(page): Promise { new GetDeckNamesRequest(), DeckNames, ); - testDeckId = deckNames.entries.find((entry) => entry.name === TEST_DECK_NAME)?.id - ?? null; + testDeckId = deckNames.entries.find((entry) => entry.name === TEST_DECK_NAME)?.id ?? null; if (testDeckId === null) { throw new Error(`Expected imported test deck "${TEST_DECK_NAME}"`); } } async function loadEditorInitial(page): Promise { - await page.waitForFunction( - () => typeof (window as any).loadNote === "function", - { timeout: 15_000 }, - ); + await page.waitForFunction(() => typeof (window as any).loadNote === "function", { + timeout: 15_000, + }); await page.evaluate(() => (window as any).loadNote({ initial: true })); await page.waitForSelector(".field-container", { timeout: 15_000 }); } @@ -179,19 +174,22 @@ test("switching notetype updates the editor fields through the Svelte path", asy await newNoteReqPromise; await expect(chooserButton(page, "notetype")).toHaveText("Cloze"); - await expect(fieldContainer(page, 0).getByText("Text", { exact: true })) - .toBeVisible(); + await expect( + fieldContainer(page, 0).getByText("Text", { exact: true }), + ).toBeVisible(); await expect( fieldContainer(page, 1).getByText("Back Extra", { exact: true }), ).toBeVisible(); - await expect(page.getByRole("button", { name: "Front", exact: true })) - .toHaveCount(0); - await expect(page.getByRole("button", { name: "Back", exact: true })) - .toHaveCount(0); + await expect(page.getByRole("button", { name: "Front", exact: true })).toHaveCount( + 0, + ); + await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount( + 0, + ); }); test("selected notetype and deck persist as context for the next note after add", async ({ editor: page }) => { - // Criterion 1 (issue #4948): assert notetype and deck persist on reopen. + // Criterion 1: assert notetype and deck persist on reopen. // Using a non-default deck so the test exercises the real // _nt_{ntid}_lastDeck persistence path rather than the fallback to the // Default deck that would pass even with broken persistence logic. @@ -199,7 +197,9 @@ test("selected notetype and deck persist as context for the next note after add" await openChooserAndSelect(page, "notetype", "Cloze"); await openChooserAndSelect(page, "deck", TEST_DECK_NAME); - const newNotePromise = page.waitForRequest(isRpc("newNote"), { timeout: 10_000 }); + const newNotePromise = page.waitForRequest(isRpc("newNote"), { + timeout: 10_000, + }); await fillAndAdd(page, "{{c1::notetype persist}}"); await newNotePromise; @@ -229,14 +229,16 @@ test("switching deck sends addNote to the selected deck", async ({ editor: page }); test("switching deck and notetype sends addNote with both selected ids", async ({ editor: page }) => { - // Criterion 3 (issue #4948): both choices persist correctly in the same session. + // Criterion 3: both choices persist correctly in the same session. // "Same session" means the choosers still reflect the selection immediately // after the add — before any explicit reopen. try { await openChooserAndSelect(page, "notetype", "Cloze"); await openChooserAndSelect(page, "deck", TEST_DECK_NAME); - const newNotePromise = page.waitForRequest(isRpc("newNote"), { timeout: 10_000 }); + const newNotePromise = page.waitForRequest(isRpc("newNote"), { + timeout: 10_000, + }); const decoded = await fillAndAdd(page, "{{c1::context answer}}"); // Payload must carry both selected ids. @@ -288,13 +290,15 @@ test("mode B: switching notetype auto-selects the last deck used with that notet }); test("reopening add mode remembers the last added deck and notetype context", async ({ editor: page }) => { - // Criterion 4 (issue #4948): assert the last used notetype and deck are + // Criterion 4: assert the last used notetype and deck are // pre-selected when the editor is reopened. try { await openChooserAndSelect(page, "notetype", "Cloze"); await openChooserAndSelect(page, "deck", TEST_DECK_NAME); - const newNotePromise = page.waitForRequest(isRpc("newNote"), { timeout: 10_000 }); + const newNotePromise = page.waitForRequest(isRpc("newNote"), { + timeout: 10_000, + }); await fillAndAdd(page, "{{c1::remembered context}}"); await newNotePromise; @@ -337,7 +341,9 @@ test("mode A: last notetype used for the current deck is restored on reopen", as await expect(chooserButton(page, "deck")).toHaveText("Default"); // Add Cloze + Default → writes _deck_{Default}_lastNotetype = Cloze. - const newNotePromise = page.waitForRequest(isRpc("newNote"), { timeout: 10_000 }); + const newNotePromise = page.waitForRequest(isRpc("newNote"), { + timeout: 10_000, + }); await fillAndAdd(page, "{{c1::mode A test}}"); await newNotePromise; From e8abda2e5d70eedc315f408ad63ce2a091c012c2 Mon Sep 17 00:00:00 2001 From: Fernando Lins <1887601+fernandolins@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:25:51 -0300 Subject: [PATCH 6/6] test(e2e): group context-switching tests into logical describe blocks --- ts/tests/e2e/context-switching.spec.ts | 523 +++++++++++++------------ 1 file changed, 265 insertions(+), 258 deletions(-) diff --git a/ts/tests/e2e/context-switching.spec.ts b/ts/tests/e2e/context-switching.spec.ts index 8441d0a5f17..bcad2608214 100644 --- a/ts/tests/e2e/context-switching.spec.ts +++ b/ts/tests/e2e/context-switching.spec.ts @@ -2,11 +2,16 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /** - * Context switching coverage. - * These tests exercise the Svelte editor's add-mode deck/notetype choosers - * end-to-end: chooser UI, reload-on-notetype-change, addNote payloads, and the - * backend default-context persistence that is written only after a successful - * add. + * Context switching coverage — grouped by concern: + * 1. Chooser UI and session behaviour — live chooser interactions within a + * single add session. + * 2. addNote payload — the IDs carried to the backend match the chooser state. + * 3. Mode B (default): each notetype remembers its own last-used deck; the + * notetype drives deck selection both live (session) and on reopen. + * 4. Mode A (AddingDefaultsToCurrentDeck=true): the current deck is fixed + * and each deck remembers the last notetype used with it. + * + * See rslib/src/adding.rs for the backend logic exercised here. */ import { SetConfigJsonRequest } from "@generated/anki/config_pb"; @@ -160,284 +165,286 @@ test.beforeEach(async ({ editor: page }) => { await reloadEditorAfterSetup(page); }); -test("switching notetype updates the editor fields through the Svelte path", async ({ editor: page }) => { - const getNotetypeReqPromise = page.waitForRequest(isRpc("getNotetype"), { - timeout: 10_000, - }); - const newNoteReqPromise = page.waitForRequest(isRpc("newNote"), { - timeout: 10_000, - }); - - await openChooserAndSelect(page, "notetype", "Cloze"); - - await getNotetypeReqPromise; - await newNoteReqPromise; +// ─── 1. Chooser UI and session behaviour ───────────────────────────────────── - await expect(chooserButton(page, "notetype")).toHaveText("Cloze"); - await expect( - fieldContainer(page, 0).getByText("Text", { exact: true }), - ).toBeVisible(); - await expect( - fieldContainer(page, 1).getByText("Back Extra", { exact: true }), - ).toBeVisible(); - await expect(page.getByRole("button", { name: "Front", exact: true })).toHaveCount( - 0, - ); - await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount( - 0, - ); -}); - -test("selected notetype and deck persist as context for the next note after add", async ({ editor: page }) => { - // Criterion 1: assert notetype and deck persist on reopen. - // Using a non-default deck so the test exercises the real - // _nt_{ntid}_lastDeck persistence path rather than the fallback to the - // Default deck that would pass even with broken persistence logic. - try { - await openChooserAndSelect(page, "notetype", "Cloze"); - await openChooserAndSelect(page, "deck", TEST_DECK_NAME); - - const newNotePromise = page.waitForRequest(isRpc("newNote"), { +test.describe("chooser UI and session behaviour", () => { + test("notetype chooser updates field list via the Svelte path", async ({ editor: page }) => { + const getNotetypeReqPromise = page.waitForRequest(isRpc("getNotetype"), { timeout: 10_000, }); - await fillAndAdd(page, "{{c1::notetype persist}}"); - await newNotePromise; - - const defaultsReqPromise = page.waitForRequest(isRpc("defaultsForAdding"), { + const newNoteReqPromise = page.waitForRequest(isRpc("newNote"), { timeout: 10_000, }); - await page.evaluate(() => (window as any).loadNote({ initial: true })); - await defaultsReqPromise; - - // currentNotetypeId = Cloze; _nt_{cloze}_lastDeck = testDeckId. - // Both must be restored through real persistence, not the Default fallback. - await expect(chooserButton(page, "notetype")).toHaveText("Cloze"); - await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); - } finally { - await restoreBasicDefault(page); - } -}); - -test("switching deck sends addNote to the selected deck", async ({ editor: page }) => { - await openChooserAndSelect(page, "deck", TEST_DECK_NAME); - await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); - - const decoded = await fillAndAdd(page, "Deck switch payload"); - expect(decoded.deckId).toBe(testDeckId); - expect(decoded.note?.notetypeId).toBe(basicNotetypeId); -}); - -test("switching deck and notetype sends addNote with both selected ids", async ({ editor: page }) => { - // Criterion 3: both choices persist correctly in the same session. - // "Same session" means the choosers still reflect the selection immediately - // after the add — before any explicit reopen. - try { await openChooserAndSelect(page, "notetype", "Cloze"); - await openChooserAndSelect(page, "deck", TEST_DECK_NAME); - - const newNotePromise = page.waitForRequest(isRpc("newNote"), { - timeout: 10_000, - }); - const decoded = await fillAndAdd(page, "{{c1::context answer}}"); - - // Payload must carry both selected ids. - expect(decoded.deckId).toBe(testDeckId); - expect(decoded.note?.notetypeId).toBe(clozeNotetypeId); - // Wait for the post-add reload to settle before checking chooser state. - await newNotePromise; + await getNotetypeReqPromise; + await newNoteReqPromise; - // Within-session context: both choosers must still reflect the selection - // immediately after the add (before any reopen). await expect(chooserButton(page, "notetype")).toHaveText("Cloze"); - await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); - } finally { - await restoreBasicDefault(page); - } + await expect( + fieldContainer(page, 0).getByText("Text", { exact: true }), + ).toBeVisible(); + await expect( + fieldContainer(page, 1).getByText("Back Extra", { exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Front", exact: true }), + ).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Back", exact: true }), + ).toHaveCount(0); + }); }); -test("mode B: switching notetype auto-selects the last deck used with that notetype", async ({ editor: page }) => { - // Adding.rs Mode B: each notetype remembers the last deck it was added to. - // When the user switches notetype via the chooser, onNotetypeChange calls - // defaultDeckForNotetype({ ntid }) and auto-selects the deck via - // deckChooser.select — without the user touching the deck chooser at all. - try { - // Step 1: Establish _nt_{cloze}_lastDeck = testDeckId. - await openChooserAndSelect(page, "notetype", "Cloze"); - await openChooserAndSelect(page, "deck", TEST_DECK_NAME); - const newNote1 = page.waitForRequest(isRpc("newNote"), { timeout: 10_000 }); - await fillAndAdd(page, "{{c1::deck mapping}}"); - await newNote1; - - // Step 2: Reset to Basic + Default so the starting state is deterministic. - await restoreBasicDefault(page); - await reloadEditorAfterSetup(page); - await expect(chooserButton(page, "notetype")).toHaveText("Basic"); - await expect(chooserButton(page, "deck")).toHaveText("Default"); - - // Step 3: Switch to Cloze. NoteEditor reads _nt_{cloze}_lastDeck and - // auto-calls deckChooser.select(testDeckId) without user interaction. - await openChooserAndSelect(page, "notetype", "Cloze"); - - // Step 4: Deck chooser must update on its own. - await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME, { - timeout: 8_000, - }); - } finally { - await restoreBasicDefault(page); - } -}); +// ─── 2. addNote payload ─────────────────────────────────────────────────────── -test("reopening add mode remembers the last added deck and notetype context", async ({ editor: page }) => { - // Criterion 4: assert the last used notetype and deck are - // pre-selected when the editor is reopened. - try { - await openChooserAndSelect(page, "notetype", "Cloze"); +test.describe("addNote payload", () => { + test("deck chooser selection is reflected in the addNote payload", async ({ editor: page }) => { await openChooserAndSelect(page, "deck", TEST_DECK_NAME); - - const newNotePromise = page.waitForRequest(isRpc("newNote"), { - timeout: 10_000, - }); - await fillAndAdd(page, "{{c1::remembered context}}"); - await newNotePromise; - - // Intermediate check: context is intact immediately after the add, - // before any explicit reopen. - await expect(chooserButton(page, "notetype")).toHaveText("Cloze"); await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); - // Reopen check: simulate re-entering Add mode (e.g. closing and - // reopening the Add Cards window). - const defaultsReqPromise = page.waitForRequest(isRpc("defaultsForAdding"), { - timeout: 10_000, - }); - await page.evaluate(() => (window as any).loadNote({ initial: true })); - await defaultsReqPromise; - - // Both must be remembered after reopen (FIX REQUIRED in PR #4029). - // Using soft assertions so both chooser states are reported even when - // the first one fails — the combined notetype+deck reopen scenario is - // known to not restore context correctly. - await expect.soft(chooserButton(page, "notetype")).toHaveText("Cloze"); - await expect.soft(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); - } finally { - await restoreBasicDefault(page); - } -}); - -test("mode A: last notetype used for the current deck is restored on reopen", async ({ editor: page }) => { - // Adding.rs Mode A (AddingDefaultsToCurrentDeck = true): the deck is the - // collection's current deck, and the notetype is the last one used with - // that deck (_deck_{did}_lastNotetype). - // Contrasts with Mode B where the notetype drives the deck selection. - try { - // Re-enable Mode A (beforeEach forced Mode B). - await setConfigJson(page, "addToCur", true); - - // In Mode A, switching notetype does NOT auto-update the deck chooser - // (the onNotetypeChange guard skips defaultDeckForNotetype when mode is A). - await openChooserAndSelect(page, "notetype", "Cloze"); - await expect(chooserButton(page, "deck")).toHaveText("Default"); - - // Add Cloze + Default → writes _deck_{Default}_lastNotetype = Cloze. - const newNotePromise = page.waitForRequest(isRpc("newNote"), { - timeout: 10_000, - }); - await fillAndAdd(page, "{{c1::mode A test}}"); - await newNotePromise; + const decoded = await fillAndAdd(page, "Deck switch payload"); - // Simulate reopen. - const defaultsReqPromise = page.waitForRequest(isRpc("defaultsForAdding"), { - timeout: 10_000, - }); - await page.evaluate(() => (window as any).loadNote({ initial: true })); - await defaultsReqPromise; + expect(decoded.deckId).toBe(testDeckId); + expect(decoded.note?.notetypeId).toBe(basicNotetypeId); + }); - // Mode A: deck = current deck (Default), notetype = _deck_{Default}_lastNotetype = Cloze. - await expect(chooserButton(page, "deck")).toHaveText("Default"); - await expect(chooserButton(page, "notetype")).toHaveText("Cloze"); - } finally { - // Restore Mode B so the next test's beforeEach starts cleanly. - await setConfigJson(page, "addToCur", false); - await restoreBasicDefault(page); - } + test("notetype and deck chooser selections both appear in the addNote payload", async ({ editor: page }) => { + // "Same session" means the choosers still reflect the selection immediately + // after the add — before any explicit reopen. + try { + await openChooserAndSelect(page, "notetype", "Cloze"); + await openChooserAndSelect(page, "deck", TEST_DECK_NAME); + + const newNotePromise = page.waitForRequest(isRpc("newNote"), { + timeout: 10_000, + }); + const decoded = await fillAndAdd(page, "{{c1::context answer}}"); + + // Payload must carry both selected ids. + expect(decoded.deckId).toBe(testDeckId); + expect(decoded.note?.notetypeId).toBe(clozeNotetypeId); + + // Wait for the post-add reload to settle before checking chooser state. + await newNotePromise; + + // Within-session context: both choosers must still reflect the selection + // immediately after the add (before any reopen). + await expect(chooserButton(page, "notetype")).toHaveText("Cloze"); + await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); + } finally { + await restoreBasicDefault(page); + } + }); }); -test("mode B: notetype with no lastDeck falls back to the collection's current deck", async ({ editor: page }) => { - // adding.rs Mode B fallback path: when _nt_{ntid}_lastDeck is absent, - // default_deck_for_notetype returns None and defaults_for_adding falls back to - // get_current_deck_for_adding(), which returns the collection's current deck. - // The test changes curDeck so that a broken fallback (e.g. always returning - // Default) is detectable. - try { - // Point _nt_{basicNotetypeId}_lastDeck at a non-existent deck — this has - // the same effect as the key being absent: get_deck() returns None and - // default_deck_for_notetype returns None, triggering the curDeck fallback. - await setConfigJson(page, `_nt_${basicNotetypeId}_lastDeck`, 999_999_999); - // Set the collection's current deck (curDeck) to the non-Default testDeck. - await setConfigJson(page, "curDeck", Number(testDeckId)); - - const defaultsReqPromise = page.waitForRequest(isRpc("defaultsForAdding"), { - timeout: 10_000, - }); - await page.evaluate(() => (window as any).loadNote({ initial: true })); - await defaultsReqPromise; +// ─── 3. Mode B: context behaviour (default) ────────────────────────────────── + +test.describe("mode B: context behaviour (default)", () => { + test("notetype switch auto-selects the last deck used with that notetype", async ({ editor: page }) => { + // adding.rs Mode B: each notetype remembers the last deck it was added to. + // When the user switches notetype via the chooser, onNotetypeChange calls + // defaultDeckForNotetype({ ntid }) and auto-selects the deck via + // deckChooser.select — without the user touching the deck chooser at all. + try { + // Step 1: Establish _nt_{cloze}_lastDeck = testDeckId. + await openChooserAndSelect(page, "notetype", "Cloze"); + await openChooserAndSelect(page, "deck", TEST_DECK_NAME); + const newNote1 = page.waitForRequest(isRpc("newNote"), { timeout: 10_000 }); + await fillAndAdd(page, "{{c1::deck mapping}}"); + await newNote1; + + // Step 2: Reset to Basic + Default so the starting state is deterministic. + await restoreBasicDefault(page); + await reloadEditorAfterSetup(page); + await expect(chooserButton(page, "notetype")).toHaveText("Basic"); + await expect(chooserButton(page, "deck")).toHaveText("Default"); + + // Step 3: Switch to Cloze. NoteEditor reads _nt_{cloze}_lastDeck and + // auto-calls deckChooser.select(testDeckId) without user interaction. + await openChooserAndSelect(page, "notetype", "Cloze"); + + // Step 4: Deck chooser must update on its own. + await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME, { + timeout: 8_000, + }); + } finally { + await restoreBasicDefault(page); + } + }); - // Deck must be testDeck (the current deck), not Default — confirming the - // fallback path reads get_current_deck_for_adding() correctly. - await expect(chooserButton(page, "notetype")).toHaveText("Basic"); - await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); - } finally { - await setConfigJson(page, "curDeck", 1); - await restoreBasicDefault(page); - } -}); + test("notetype and deck context persists after add and across reopen", async ({ editor: page }) => { + // Using a non-default deck exercises the real _nt_{ntid}_lastDeck persistence + // path rather than the Default fallback that would pass even with broken logic. + try { + await openChooserAndSelect(page, "notetype", "Cloze"); + await openChooserAndSelect(page, "deck", TEST_DECK_NAME); + + const newNotePromise = page.waitForRequest(isRpc("newNote"), { + timeout: 10_000, + }); + await fillAndAdd(page, "{{c1::notetype persist}}"); + await newNotePromise; + + const defaultsReqPromise = page.waitForRequest(isRpc("defaultsForAdding"), { + timeout: 10_000, + }); + await page.evaluate(() => (window as any).loadNote({ initial: true })); + await defaultsReqPromise; + + // currentNotetypeId = Cloze; _nt_{cloze}_lastDeck = testDeckId. + // Both must be restored through real persistence, not the Default fallback. + await expect(chooserButton(page, "notetype")).toHaveText("Cloze"); + await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); + } finally { + await restoreBasicDefault(page); + } + }); -test("mode B: deleted lastDeck is ignored and current deck is used instead", async ({ editor: page }) => { - // adding.rs: default_deck_for_notetype calls get_deck(last_deck_id) and skips - // if None (deck was deleted). The fallback must produce curDeck, not Default. - try { - await setConfigJson(page, `_nt_${basicNotetypeId}_lastDeck`, 999_999_999); - await setConfigJson(page, "curDeck", Number(testDeckId)); + test("notetype and deck remain selected within session and after explicit reopen", async ({ editor: page }) => { + try { + await openChooserAndSelect(page, "notetype", "Cloze"); + await openChooserAndSelect(page, "deck", TEST_DECK_NAME); + + const newNotePromise = page.waitForRequest(isRpc("newNote"), { + timeout: 10_000, + }); + await fillAndAdd(page, "{{c1::remembered context}}"); + await newNotePromise; + + // Within-session check: context must be intact immediately after the add, + // before any explicit reopen. + await expect(chooserButton(page, "notetype")).toHaveText("Cloze"); + await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); + + // Reopen check: simulate re-entering Add mode (e.g. closing and + // reopening the Add Cards window). + const defaultsReqPromise = page.waitForRequest(isRpc("defaultsForAdding"), { + timeout: 10_000, + }); + await page.evaluate(() => (window as any).loadNote({ initial: true })); + await defaultsReqPromise; + + // Using soft assertions so both chooser states are reported even if one + // fails — the combined notetype+deck reopen scenario is the primary + // indicator of the persistence bug documented in PR #4029. + await expect.soft(chooserButton(page, "notetype")).toHaveText("Cloze"); + await expect.soft(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); + } finally { + await restoreBasicDefault(page); + } + }); - const defaultsReqPromise = page.waitForRequest(isRpc("defaultsForAdding"), { - timeout: 10_000, - }); - await page.evaluate(() => (window as any).loadNote({ initial: true })); - await defaultsReqPromise; + test("missing lastDeck history falls back to the current deck on reopen", async ({ editor: page }) => { + // adding.rs: when _nt_{ntid}_lastDeck is absent, default_deck_for_notetype + // returns None and defaults_for_adding falls back to get_current_deck_for_adding(). + // curDeck is set to testDeck so a broken fallback (e.g. always Default) is detectable. + try { + // Point _nt_{basicNotetypeId}_lastDeck at a non-existent deck — same effect + // as the key being absent: get_deck() returns None, triggering the curDeck fallback. + await setConfigJson(page, `_nt_${basicNotetypeId}_lastDeck`, 999_999_999); + await setConfigJson(page, "curDeck", Number(testDeckId)); + + const defaultsReqPromise = page.waitForRequest(isRpc("defaultsForAdding"), { + timeout: 10_000, + }); + await page.evaluate(() => (window as any).loadNote({ initial: true })); + await defaultsReqPromise; + + // Deck must be testDeck (the current deck), not Default. + await expect(chooserButton(page, "notetype")).toHaveText("Basic"); + await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); + } finally { + await setConfigJson(page, "curDeck", 1); + await restoreBasicDefault(page); + } + }); - await expect(chooserButton(page, "notetype")).toHaveText("Basic"); - await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); - } finally { - await setConfigJson(page, "curDeck", 1); - await restoreBasicDefault(page); - } + test("stale lastDeck reference falls back to the current deck on reopen", async ({ editor: page }) => { + // adding.rs: default_deck_for_notetype calls get_deck(last_deck_id) and returns + // None when the deck no longer exists. The fallback must produce curDeck, not Default. + try { + await setConfigJson(page, `_nt_${basicNotetypeId}_lastDeck`, 999_999_999); + await setConfigJson(page, "curDeck", Number(testDeckId)); + + const defaultsReqPromise = page.waitForRequest(isRpc("defaultsForAdding"), { + timeout: 10_000, + }); + await page.evaluate(() => (window as any).loadNote({ initial: true })); + await defaultsReqPromise; + + await expect(chooserButton(page, "notetype")).toHaveText("Basic"); + await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); + } finally { + await setConfigJson(page, "curDeck", 1); + await restoreBasicDefault(page); + } + }); }); -test("mode A: deck with no lastNotetype falls back to the global current notetype", async ({ editor: page }) => { - // adding.rs Mode A fallback: when _deck_{did}_lastNotetype is absent, - // default_notetype_for_deck falls back to get_current_notetype_for_adding(), - // which returns the global current notetype. restoreBasicDefault() (run by - // previous tests and in finally blocks) leaves that global notetype as Basic. - try { - await setConfigJson(page, "addToCur", true); - // NotetypeId 0 does not exist → get_notetype(0) returns None → fallback. - await setConfigJson(page, `_deck_${testDeckId}_lastNotetype`, 0); - await setConfigJson(page, "curDeck", Number(testDeckId)); - - const defaultsReqPromise = page.waitForRequest(isRpc("defaultsForAdding"), { - timeout: 10_000, - }); - await page.evaluate(() => (window as any).loadNote({ initial: true })); - await defaultsReqPromise; +// ─── 4. Mode A: reopen context (deck-centric) ──────────────────────────────── + +test.describe("mode A: reopen context (deck-centric)", () => { + test("last notetype for the current deck is restored on reopen", async ({ editor: page }) => { + // adding.rs Mode A (AddingDefaultsToCurrentDeck = true): deck = collection's + // current deck, notetype = last notetype used with that deck (_deck_{did}_lastNotetype). + // Contrasts with Mode B where the notetype drives the deck selection. + try { + // Re-enable Mode A (beforeEach forced Mode B). + await setConfigJson(page, "addToCur", true); + + // In Mode A, switching notetype does NOT auto-update the deck chooser + // (the onNotetypeChange guard skips defaultDeckForNotetype when mode is A). + await openChooserAndSelect(page, "notetype", "Cloze"); + await expect(chooserButton(page, "deck")).toHaveText("Default"); + + // Add Cloze + Default → writes _deck_{Default}_lastNotetype = Cloze. + const newNotePromise = page.waitForRequest(isRpc("newNote"), { + timeout: 10_000, + }); + await fillAndAdd(page, "{{c1::mode A test}}"); + await newNotePromise; + + const defaultsReqPromise = page.waitForRequest(isRpc("defaultsForAdding"), { + timeout: 10_000, + }); + await page.evaluate(() => (window as any).loadNote({ initial: true })); + await defaultsReqPromise; + + // Mode A: deck = current deck (Default), notetype = _deck_{Default}_lastNotetype = Cloze. + await expect(chooserButton(page, "deck")).toHaveText("Default"); + await expect(chooserButton(page, "notetype")).toHaveText("Cloze"); + } finally { + // Restore Mode B so the next test's beforeEach starts cleanly. + await setConfigJson(page, "addToCur", false); + await restoreBasicDefault(page); + } + }); - // Mode A: deck = testDeck (curDeck), notetype = Basic (global current notetype fallback). - await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); - await expect(chooserButton(page, "notetype")).toHaveText("Basic"); - } finally { - await setConfigJson(page, "addToCur", false); - await setConfigJson(page, "curDeck", 1); - await restoreBasicDefault(page); - } + test("missing deck–notetype history falls back to the global notetype on reopen", async ({ editor: page }) => { + // adding.rs Mode A fallback: when _deck_{did}_lastNotetype is absent, + // default_notetype_for_deck falls back to get_current_notetype_for_adding(). + // restoreBasicDefault() leaves the global notetype as Basic. + try { + await setConfigJson(page, "addToCur", true); + // NotetypeId 0 does not exist → get_notetype(0) returns None → fallback. + await setConfigJson(page, `_deck_${testDeckId}_lastNotetype`, 0); + await setConfigJson(page, "curDeck", Number(testDeckId)); + + const defaultsReqPromise = page.waitForRequest(isRpc("defaultsForAdding"), { + timeout: 10_000, + }); + await page.evaluate(() => (window as any).loadNote({ initial: true })); + await defaultsReqPromise; + + // Mode A: deck = testDeck (curDeck), notetype = Basic (global fallback). + await expect(chooserButton(page, "deck")).toHaveText(TEST_DECK_NAME); + await expect(chooserButton(page, "notetype")).toHaveText("Basic"); + } finally { + await setConfigJson(page, "addToCur", false); + await setConfigJson(page, "curDeck", 1); + await restoreBasicDefault(page); + } + }); });