diff --git a/frontend/__tests__/root/config.spec.ts b/frontend/__tests__/root/config.spec.ts index d8c881e5588f..d2a907c0e21e 100644 --- a/frontend/__tests__/root/config.spec.ts +++ b/frontend/__tests__/root/config.spec.ts @@ -56,9 +56,16 @@ describe("Config", () => { vi.useFakeTimers(); mocks.forEach((it) => it.mockClear()); - vi.mock("../../src/ts/test/test-state", () => ({ - isActive: true, - })); + vi.mock("../../src/ts/test/test-state", async (importOriginal) => { + const actual = + await importOriginal(); + + return { + ...actual, + isActive: true, + resetQuoteHistory: vi.fn(), + }; + }); isConfigValueValidMock.mockReturnValue(true); canSetConfigWithCurrentFunboxesMock.mockReturnValue(true); diff --git a/frontend/src/html/pages/test.html b/frontend/src/html/pages/test.html index fa9ed4bd9913..fa087af6592b 100644 --- a/frontend/src/html/pages/test.html +++ b/frontend/src/html/pages/test.html @@ -162,14 +162,25 @@ - +
+ + + +
diff --git a/frontend/src/styles/media-queries-blue.scss b/frontend/src/styles/media-queries-blue.scss index b3634b288738..85e5d4799a90 100644 --- a/frontend/src/styles/media-queries-blue.scss +++ b/frontend/src/styles/media-queries-blue.scss @@ -136,6 +136,7 @@ } } @media (pointer: coarse) and (max-width: 778px) { + #previousTestButton, #restartTestButton { display: block !important; } diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index daa6e532be96..970c07172737 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -1321,11 +1321,21 @@ margin-left: 0.5em; } -#restartTestButton { +#testActionButtons { + display: flex; + justify-content: center; + align-items: center; + gap: 0.75rem; + margin: 1rem auto 0; +} + +.testActionButton { font-size: 1rem; - margin: 1rem auto 0 auto; display: flex; + align-items: center; + justify-content: center; padding: 1em 2em; + margin: 0; } #compositionDisplay { diff --git a/frontend/src/ts/commandline/lists/navigation.ts b/frontend/src/ts/commandline/lists/navigation.ts index caf68b4cefa4..3f51c524e4be 100644 --- a/frontend/src/ts/commandline/lists/navigation.ts +++ b/frontend/src/ts/commandline/lists/navigation.ts @@ -2,6 +2,9 @@ import { navigate } from "../../controllers/route-controller"; import { isAuthenticated } from "../../firebase"; import { toggleFullscreen } from "../../utils/misc"; import { Command } from "../types"; +import { Config } from "../../config/store"; +import * as TestState from "../../test/test-state"; +import * as TestLogic from "../../test/test-logic"; const commands: Command[] = [ { @@ -58,6 +61,15 @@ const commands: Command[] = [ toggleFullscreen(); }, }, + { + id: "previousTest", + display: "Previous Test (Quotes)", + icon: "fa-undo-alt", + available: () => Config.mode === "quote" && TestState.quoteHistoryIndex > 0, + exec: (): void => { + TestLogic.restart({ isPrevious: true }); + }, + }, ]; export default commands; diff --git a/frontend/src/ts/config/setters.ts b/frontend/src/ts/config/setters.ts index 385b948fc428..e466da514d93 100644 --- a/frontend/src/ts/config/setters.ts +++ b/frontend/src/ts/config/setters.ts @@ -121,6 +121,15 @@ export function setConfig( } Config[key] = value; + + if (key === "mode" && previousValue === "quote" && value !== "quote") { + TestState.resetQuoteHistory(); + } + + if (key === "language" && previousValue !== value) { + TestState.resetQuoteHistory(); + } + if (!options?.nosave) saveToLocalStorage(key, options?.nosave); // @ts-expect-error i can't figure this out diff --git a/frontend/src/ts/event-handlers/test.ts b/frontend/src/ts/event-handlers/test.ts index 1fc30c519a2b..f724ededb039 100644 --- a/frontend/src/ts/event-handlers/test.ts +++ b/frontend/src/ts/event-handlers/test.ts @@ -6,6 +6,9 @@ import * as EditResultTagsModal from "../modals/edit-result-tags"; import * as MobileTestConfigModal from "../modals/mobile-test-config"; import * as CustomTestDurationModal from "../modals/custom-test-duration"; import * as TestWords from "../test/test-words"; +import * as TestLogic from "../test/test-logic"; +import * as TestState from "../test/test-state"; +import * as TestUI from "../test/test-ui"; import { showNoticeNotification, showErrorNotification, @@ -120,3 +123,10 @@ qs(".pageTest #dailyLeaderboardRank")?.on("click", async () => { )}&goToUserPage=true`, ); }); + +testPage?.onChild("click", "#previousTestButton", () => { + if (TestUI.resultCalculating) return; + if (Config.mode === "quote" && TestState.quoteHistoryIndex > 0) { + TestLogic.restart({ isPrevious: true }); + } +}); diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 8db390113010..172f91188064 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -169,6 +169,7 @@ type RestartOptions = { practiseMissed?: boolean; noAnim?: boolean; isQuickRestart?: boolean; + isPrevious?: boolean; }; export function restart(options = {} as RestartOptions): void { @@ -346,7 +347,7 @@ export function restart(options = {} as RestartOptions): void { TestState.setPaceRepeat(repeatWithPace); TestInitFailed.hide(); TestState.setTestInitSuccess(true); - const initResult = await init(); + const initResult = await init(options.isPrevious ?? false); if (!initResult) { TestState.setTestRestarting(false); @@ -380,7 +381,7 @@ let lastInitError: Error | null = null; let showedLazyModeNotification: boolean = false; let testReinitCount = 0; -async function init(): Promise { +async function init(loadPreviousQuote = false): Promise { console.debug("Initializing test"); testReinitCount++; if (testReinitCount > 3) { @@ -413,7 +414,7 @@ async function init(): Promise { } if (!language || language.name !== Config.language) { - return await init(); + return await init(loadPreviousQuote); } if (getActivePage() === "test") { @@ -512,7 +513,9 @@ async function init(): Promise { let generatedWords: string[] = []; let generatedSectionIndexes: number[] = []; try { - const gen = await WordsGenerator.generateWords(language); + const gen = await WordsGenerator.generateWords(language, { + loadPreviousQuote, + }); generatedWords = gen.words; generatedSectionIndexes = gen.sectionIndexes; wordsHaveTab = gen.hasTab; @@ -537,7 +540,7 @@ async function init(): Promise { }); } - return await init(); + return await init(loadPreviousQuote); } let hasNumbers = false; diff --git a/frontend/src/ts/test/test-state.ts b/frontend/src/ts/test/test-state.ts index c7c91884399f..3730d110b6b3 100644 --- a/frontend/src/ts/test/test-state.ts +++ b/frontend/src/ts/test/test-state.ts @@ -13,6 +13,11 @@ export let isLanguageRightToLeft = false; export let isDirectionReversed = false; export let testRestarting = false; export let resultVisible = false; +/** Max quotes remembered for "previous quote" navigation (per session). */ +export const MAX_QUOTE_HISTORY_LENGTH = 50; + +export const quoteHistory: number[] = []; +export let quoteHistoryIndex = -1; export function setRepeated(tf: boolean): void { isRepeated = tf; @@ -82,3 +87,42 @@ export function setTestRestarting(val: boolean): void { export function setResultVisible(val: boolean): void { resultVisible = val; } + +/** + * Id of the quote that would load if we navigate back, without mutating history. + * Call {@link commitPreviousNavigation} only after that quote was resolved successfully. + */ +export function peekPreviousQuoteId(): number | null { + if (quoteHistoryIndex <= 0) { + return null; + } + const val = quoteHistory[quoteHistoryIndex - 1]; + return val ?? null; +} + +/** Apply a successful "previous quote" navigation (decrements the history cursor). */ +export function commitPreviousNavigation(): void { + if (quoteHistoryIndex <= 0) { + return; + } + quoteHistoryIndex--; +} + +export function pushQuoteToHistory(quoteId: number): void { + if (quoteHistoryIndex < quoteHistory.length - 1) { + quoteHistory.splice(quoteHistoryIndex + 1); + } + + quoteHistory.push(quoteId); + quoteHistoryIndex++; + + if (quoteHistory.length > MAX_QUOTE_HISTORY_LENGTH) { + quoteHistory.shift(); + quoteHistoryIndex--; + } +} + +export function resetQuoteHistory(): void { + quoteHistory.length = 0; + quoteHistoryIndex = -1; +} diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 5bd1f63d7463..739b04e44bb4 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -1935,6 +1935,7 @@ export function onTestRestart(source: "testPage" | "resultPage"): void { void SoundController.clearAllSounds(); cancelPendingAnimationFramesStartingWith("test-ui"); showWords(); + updatePreviousButtonVisibility(); } export function onTestFinish(): void { @@ -2090,4 +2091,18 @@ configEvent.subscribe(({ key, newValue }) => { if (["tapeMode", "tapeMargin"].includes(key)) { updateLiveStatsMargin(); } + if (key === "mode" || key === "language") { + updatePreviousButtonVisibility(); + } }); + +export function updatePreviousButtonVisibility(): void { + const prevBtn = document.getElementById("previousTestButton"); + if (!prevBtn) return; + + if (Config.mode === "quote" && TestState.quoteHistoryIndex > 0) { + prevBtn.classList.remove("hidden"); + } else { + prevBtn.classList.add("hidden"); + } +} diff --git a/frontend/src/ts/test/words-generator.ts b/frontend/src/ts/test/words-generator.ts index 4302df510bfe..937eebbcd03f 100644 --- a/frontend/src/ts/test/words-generator.ts +++ b/frontend/src/ts/test/words-generator.ts @@ -496,7 +496,8 @@ export function getLimit(): number { async function getQuoteWordList( language: LanguageObject, - wordOrder?: FunboxWordOrder, + wordOrder: FunboxWordOrder | undefined, + options: GenerateWordsOptions, ): Promise { if (TestState.isRepeated) { if (currentWordset === null) { @@ -534,33 +535,59 @@ async function getQuoteWordList( } let rq: Quote; - if (Config.quoteLength.includes(-2) && Config.quoteLength.length === 1) { - const targetQuote = QuotesController.getQuoteById( - TestState.selectedQuoteId, - ); - if (targetQuote === undefined) { - setQuoteLengthAll(); - throw new WordGenError( - `Quote ${TestState.selectedQuoteId} does not exist`, + let wasPrevious = false; + + function pickQuoteFromCurrentSettings(): Quote { + if (Config.quoteLength.includes(-2) && Config.quoteLength.length === 1) { + const targetQuote = QuotesController.getQuoteById( + TestState.selectedQuoteId, ); + if (targetQuote === undefined) { + setQuoteLengthAll(); + throw new WordGenError( + `Quote ${TestState.selectedQuoteId} does not exist`, + ); + } + return targetQuote; } - rq = targetQuote; - } else if (Config.quoteLength.includes(-3)) { - const randomQuote = QuotesController.getRandomFavoriteQuote( - Config.language, - ); - if (randomQuote === null) { - setQuoteLengthAll(); - throw new WordGenError("No favorite quotes found"); + if (Config.quoteLength.includes(-3)) { + const randomQuote = QuotesController.getRandomFavoriteQuote( + Config.language, + ); + if (randomQuote === null) { + setQuoteLengthAll(); + throw new WordGenError("No favorite quotes found"); + } + return randomQuote; } - rq = randomQuote; - } else { const randomQuote = QuotesController.getRandomQuote(); if (randomQuote === null) { setQuoteLengthAll(); throw new WordGenError("No quotes found for selected quote length"); } - rq = randomQuote; + return randomQuote; + } + + if (options.loadPreviousQuote) { + const prevQuoteId = TestState.peekPreviousQuoteId(); + if (prevQuoteId !== null) { + const targetQuote = QuotesController.getQuoteById(prevQuoteId); + if (targetQuote === undefined) { + setQuoteLengthAll(); + throw new WordGenError(`Quote ${prevQuoteId} does not exist`); + } + TestState.commitPreviousNavigation(); + rq = targetQuote; + wasPrevious = true; + } else { + rq = pickQuoteFromCurrentSettings(); + } + } else { + rq = pickQuoteFromCurrentSettings(); + } + + if (!TestState.isRepeated && !wasPrevious) { + TestState.pushQuoteToHistory(rq.id); } rq.language = Strings.removeLanguageSize(Config.language); @@ -605,10 +632,17 @@ type GenerateWordsReturn = { allLigatures?: boolean; }; +/** Options for a single generation pass (explicit intent, not global test flags). */ +export type GenerateWordsOptions = { + /** Load the previous quote from session history (quote mode only). */ + loadPreviousQuote?: boolean; +}; + let previousRandomQuote: QuoteWithTextSplit | null = null; export async function generateWords( language: LanguageObject, + options: GenerateWordsOptions = {}, ): Promise { if (!TestState.isRepeated) { previousGetNextWordReturns = []; @@ -637,7 +671,7 @@ export async function generateWords( if (Config.mode === "custom") { wordList = CustomText.getText(); } else if (Config.mode === "quote") { - wordList = await getQuoteWordList(language, wordOrder); + wordList = await getQuoteWordList(language, wordOrder, options); } else if (Config.mode === "zen") { wordList = []; }