Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions frontend/src/html/pages/test.html
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,25 @@

<div id="keymap" class="hidden"></div>

<button
id="restartTestButton"
aria-label="Restart Test"
data-balloon-pos="down"
class="text"
>
<i class="fas fa-fw fa-redo-alt"></i>
</button>
<div id="testActionButtons">
<button
id="previousTestButton"
aria-label="Previous Test"
data-balloon-pos="down"
class="text testActionButton hidden"
>
<i class="fas fa-fw fa-undo-alt"></i>
</button>

<button
id="restartTestButton"
aria-label="Restart Test"
data-balloon-pos="down"
class="text testActionButton"
>
<i class="fas fa-fw fa-redo-alt"></i>
</button>
</div>
<div id="liveStatsTextBottom" class="timerMain">
<div class="wrapper">
<div class="liveSpeed hidden">123</div>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/styles/media-queries-blue.scss
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@
}
}
@media (pointer: coarse) and (max-width: 778px) {
#previousTestButton,
#restartTestButton {
display: block !important;
}
Expand Down
14 changes: 12 additions & 2 deletions frontend/src/styles/test.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/ts/commandline/lists/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
{
Expand Down Expand Up @@ -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;
9 changes: 9 additions & 0 deletions frontend/src/ts/config/setters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@
}

Config[key] = value;

if (key === "mode" && previousValue === "quote" && value !== "quote") {
TestState.resetQuoteHistory();

Check failure on line 126 in frontend/src/ts/config/setters.ts

View workflow job for this annotation

GitHub Actions / ci-fe

[unit] __tests__/root/config.spec.ts > Config > apply > should not enable minWpm if not provided

Error: [vitest] No "resetQuoteHistory" export is defined on the "../../src/ts/test/test-state" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("../../src/ts/test/test-state"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ setConfig src/ts/config/setters.ts:126:15 ❯ setConfig src/ts/config/setters.ts:114:19 ❯ Module.applyConfig src/ts/config/lifecycle.ts:96:17 ❯ __tests__/root/config.spec.ts:396:23

Check failure on line 126 in frontend/src/ts/config/setters.ts

View workflow job for this annotation

GitHub Actions / ci-fe

[unit] __tests__/root/config.spec.ts > Config > apply > should apply a partial config but keep the rest unchanged

Error: [vitest] No "resetQuoteHistory" export is defined on the "../../src/ts/test/test-state" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("../../src/ts/test/test-state"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ setConfig src/ts/config/setters.ts:126:15 ❯ setConfig src/ts/config/setters.ts:114:19 ❯ Module.applyConfig src/ts/config/lifecycle.ts:96:17 ❯ __tests__/root/config.spec.ts:384:23

Check failure on line 126 in frontend/src/ts/config/setters.ts

View workflow job for this annotation

GitHub Actions / ci-fe

[unit] __tests__/root/config.spec.ts > Config > apply > should apply keys in an order to avoid overrides > 'quote length shouldnt override mode, …'

Error: [vitest] No "resetQuoteHistory" export is defined on the "../../src/ts/test/test-state" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("../../src/ts/test/test-state"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ setConfig src/ts/config/setters.ts:126:15 ❯ setConfig src/ts/config/setters.ts:114:19 ❯ Module.applyConfig src/ts/config/lifecycle.ts:96:17 ❯ __tests__/root/config.spec.ts:369:25

Check failure on line 126 in frontend/src/ts/config/setters.ts

View workflow job for this annotation

GitHub Actions / ci-fe

[unit] __tests__/root/config.spec.ts > Config > apply > should reset to default if setting failed > 'applies config migration'

Error: [vitest] No "resetQuoteHistory" export is defined on the "../../src/ts/test/test-state" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("../../src/ts/test/test-state"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ setConfig src/ts/config/setters.ts:126:15 ❯ setConfig src/ts/config/setters.ts:114:19 ❯ Module.applyConfig src/ts/config/lifecycle.ts:96:17 ❯ __tests__/root/config.spec.ts:332:25

Check failure on line 126 in frontend/src/ts/config/setters.ts

View workflow job for this annotation

GitHub Actions / ci-fe

[unit] __tests__/root/config.spec.ts > Config > apply > should reset to default if setting failed > 'sanitizes config, remove extra keys'

Error: [vitest] No "resetQuoteHistory" export is defined on the "../../src/ts/test/test-state" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("../../src/ts/test/test-state"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ setConfig src/ts/config/setters.ts:126:15 ❯ setConfig src/ts/config/setters.ts:114:19 ❯ Module.applyConfig src/ts/config/lifecycle.ts:96:17 ❯ __tests__/root/config.spec.ts:332:25

Check failure on line 126 in frontend/src/ts/config/setters.ts

View workflow job for this annotation

GitHub Actions / ci-fe

[unit] __tests__/root/config.spec.ts > Config > apply > should reset to default if setting failed > 'invalid combination of funboxes'

Error: [vitest] No "resetQuoteHistory" export is defined on the "../../src/ts/test/test-state" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("../../src/ts/test/test-state"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ setConfig src/ts/config/setters.ts:126:15 ❯ setConfig src/ts/config/setters.ts:114:19 ❯ Module.applyConfig src/ts/config/lifecycle.ts:96:17 ❯ __tests__/root/config.spec.ts:332:25

Check failure on line 126 in frontend/src/ts/config/setters.ts

View workflow job for this annotation

GitHub Actions / ci-fe

[unit] __tests__/root/config.spec.ts > Config > apply > should reset to default if setting failed > 'mode incompatible with funbox'

Error: [vitest] No "resetQuoteHistory" export is defined on the "../../src/ts/test/test-state" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("../../src/ts/test/test-state"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ setConfig src/ts/config/setters.ts:126:15 ❯ setConfig src/ts/config/setters.ts:114:19 ❯ Module.applyConfig src/ts/config/lifecycle.ts:96:17 ❯ __tests__/root/config.spec.ts:332:25

Check failure on line 126 in frontend/src/ts/config/setters.ts

View workflow job for this annotation

GitHub Actions / ci-fe

[unit] __tests__/root/config.spec.ts > Config > apply > should reset to default if setting failed > 'invalid funbox'

Error: [vitest] No "resetQuoteHistory" export is defined on the "../../src/ts/test/test-state" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("../../src/ts/test/test-state"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ setConfig src/ts/config/setters.ts:126:15 ❯ setConfig src/ts/config/setters.ts:114:19 ❯ Module.applyConfig src/ts/config/lifecycle.ts:96:17 ❯ __tests__/root/config.spec.ts:332:25

Check failure on line 126 in frontend/src/ts/config/setters.ts

View workflow job for this annotation

GitHub Actions / ci-fe

[unit] __tests__/root/config.spec.ts > Config > apply > should fill missing values with defaults

Error: [vitest] No "resetQuoteHistory" export is defined on the "../../src/ts/test/test-state" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("../../src/ts/test/test-state"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ setConfig src/ts/config/setters.ts:126:15 ❯ setConfig src/ts/config/setters.ts:114:19 ❯ Module.applyConfig src/ts/config/lifecycle.ts:96:17 ❯ __tests__/root/config.spec.ts:287:23
}

if (key === "language" && previousValue !== value) {
TestState.resetQuoteHistory();
}

if (!options?.nosave) saveToLocalStorage(key, options?.nosave);

// @ts-expect-error i can't figure this out
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/ts/event-handlers/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
}
});
13 changes: 8 additions & 5 deletions frontend/src/ts/test/test-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ type RestartOptions = {
practiseMissed?: boolean;
noAnim?: boolean;
isQuickRestart?: boolean;
isPrevious?: boolean;
};
Comment on lines 169 to 173
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

restart({ isPrevious: true }) can be overridden by existing logic that forces options.withSameWordset = true when repeatQuotes === "typing" (and in repeated tests). In that case TestState.isRepeated short-circuits quote selection, so “Previous Test” won’t actually navigate back. Consider explicitly disabling withSameWordset when isPrevious is set (or otherwise ensuring the previous-quote path wins).

Copilot uses AI. Check for mistakes.

export function restart(options = {} as RestartOptions): void {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -380,7 +381,7 @@ let lastInitError: Error | null = null;
let showedLazyModeNotification: boolean = false;
let testReinitCount = 0;

async function init(): Promise<boolean> {
async function init(loadPreviousQuote = false): Promise<boolean> {
console.debug("Initializing test");
testReinitCount++;
if (testReinitCount > 3) {
Expand Down Expand Up @@ -413,7 +414,7 @@ async function init(): Promise<boolean> {
}

if (!language || language.name !== Config.language) {
return await init();
return await init(loadPreviousQuote);
}

if (getActivePage() === "test") {
Expand Down Expand Up @@ -512,7 +513,9 @@ async function init(): Promise<boolean> {
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;
Expand All @@ -537,7 +540,7 @@ async function init(): Promise<boolean> {
});
}

return await init();
return await init(loadPreviousQuote);
}

let hasNumbers = false;
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/ts/test/test-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
15 changes: 15 additions & 0 deletions frontend/src/ts/test/test-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1935,6 +1935,7 @@ export function onTestRestart(source: "testPage" | "resultPage"): void {
void SoundController.clearAllSounds();
cancelPendingAnimationFramesStartingWith("test-ui");
showWords();
updatePreviousButtonVisibility();
}

export function onTestFinish(): void {
Expand Down Expand Up @@ -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");
}
}
76 changes: 55 additions & 21 deletions frontend/src/ts/test/words-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,8 @@ export function getLimit(): number {

async function getQuoteWordList(
language: LanguageObject,
wordOrder?: FunboxWordOrder,
wordOrder: FunboxWordOrder | undefined,
options: GenerateWordsOptions,
): Promise<string[]> {
if (TestState.isRepeated) {
if (currentWordset === null) {
Expand Down Expand Up @@ -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;
Comment on lines +571 to +581
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

commitPreviousNavigation() mutates quote history inside generateWords flow. init() retries on any WordGenError/Error (calls init(loadPreviousQuote) again), so if a later step throws (e.g. funbox withWords, getNextWord, etc.), the history cursor will already be decremented and the retry will skip an extra quote / lose the ability to navigate back. Safer: only commit the cursor after full word generation + init succeeds, or add rollback on failure.

Copilot uses AI. Check for mistakes.
} else {
rq = pickQuoteFromCurrentSettings();
}
} else {
rq = pickQuoteFromCurrentSettings();
}

if (!TestState.isRepeated && !wasPrevious) {
TestState.pushQuoteToHistory(rq.id);
}

rq.language = Strings.removeLanguageSize(Config.language);
Expand Down Expand Up @@ -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<GenerateWordsReturn> {
if (!TestState.isRepeated) {
previousGetNextWordReturns = [];
Expand Down Expand Up @@ -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 = [];
}
Expand Down
Loading