diff --git a/frontend/__tests__/utils/misc.spec.ts b/frontend/__tests__/utils/misc.spec.ts index 893282946..669371c46 100644 --- a/frontend/__tests__/utils/misc.spec.ts +++ b/frontend/__tests__/utils/misc.spec.ts @@ -1,5 +1,10 @@ -import { describe, it, expect } from "vitest"; -import { getErrorMessage, isObject, escapeHTML } from "../../src/ts/utils/misc"; +import { describe, it, expect, vi } from "vitest"; +import { + getErrorMessage, + isObject, + escapeHTML, + promiseWithResolvers, +} from "../../src/ts/utils/misc"; import { getLanguageDisplayString, removeLanguageSize, @@ -218,4 +223,231 @@ describe("misc.ts", () => { }); }); }); + + describe("promiseWithResolvers", () => { + it("should resolve the promise from outside", async () => { + //GIVEN + const { promise, resolve } = promiseWithResolvers(); + + //WHEN + resolve(42); + + //THEN + await expect(promise).resolves.toBe(42); + }); + + it("should resolve new promise after reset using same promise reference", async () => { + const { promise, resolve, reset } = promiseWithResolvers(); + const firstPromise = promise; + + reset(); + + resolve(10); + + await expect(firstPromise).resolves.toBe(10); + expect(promise).toBe(firstPromise); + }); + + it("should reject the promise from outside", async () => { + //GIVEN + const { promise, reject } = promiseWithResolvers(); + const error = new Error("test error"); + + //WHEN + reject(error); + + //THEN + await expect(promise).rejects.toThrow("test error"); + }); + + it("should work with void type", async () => { + //GIVEN + const { promise, resolve } = promiseWithResolvers(); + + //WHEN + resolve(); + + //THEN + await expect(promise).resolves.toBeUndefined(); + }); + + it("should allow multiple resolves (only first takes effect)", async () => { + //GIVEN + const { promise, resolve } = promiseWithResolvers(); + + //WHEN + resolve(42); + resolve(100); // This should have no effect + + //THEN + await expect(promise).resolves.toBe(42); + }); + + it("should reset and create a new promise", async () => { + //GIVEN + const { promise, resolve, reset } = promiseWithResolvers(); + resolve(42); + + //WHEN + reset(); + resolve(100); + + //THEN + await expect(promise).resolves.toBe(100); + }); + + it("should keep the same promise reference after reset", async () => { + //GIVEN + const wrapper = promiseWithResolvers(); + const firstPromise = wrapper.promise; + wrapper.resolve(42); + await expect(firstPromise).resolves.toBe(42); + + //WHEN + wrapper.reset(); + const secondPromise = wrapper.promise; + wrapper.resolve(100); + + //THEN + expect(firstPromise).toBe(secondPromise); // Same reference + await expect(wrapper.promise).resolves.toBe(100); + }); + + it("should allow reject after reset", async () => { + //GIVEN + const wrapper = promiseWithResolvers(); + wrapper.resolve(42); + await wrapper.promise; + + //WHEN + wrapper.reset(); + const error = new Error("after reset"); + wrapper.reject(error); + + //THEN + await expect(wrapper.promise).rejects.toThrow("after reset"); + }); + + it("should work with complex types", async () => { + //GIVEN + type ComplexType = { id: number; data: string[] }; + const { promise, resolve } = promiseWithResolvers(); + const data: ComplexType = { id: 1, data: ["a", "b", "c"] }; + + //WHEN + resolve(data); + + //THEN + await expect(promise).resolves.toEqual(data); + }); + + it("should handle rejection with non-Error values", async () => { + //GIVEN + const { promise, reject } = promiseWithResolvers(); + + //WHEN + reject("string error"); + + //THEN + await expect(promise).rejects.toBe("string error"); + }); + + it("should allow chaining with then/catch", async () => { + //GIVEN + const { promise, resolve } = promiseWithResolvers(); + const onFulfilled = vi.fn((value) => value * 2); + const chained = promise.then(onFulfilled); + + //WHEN + resolve(21); + + //THEN + await expect(chained).resolves.toBe(42); + expect(onFulfilled).toHaveBeenCalledWith(21); + }); + + it("should support async/await patterns", async () => { + //GIVEN + const { promise, resolve } = promiseWithResolvers(); + + //WHEN + setTimeout(() => resolve("delayed"), 10); + + //THEN + const result = await promise; + expect(result).toBe("delayed"); + }); + + it("should resolve old promise reference after reset", async () => { + //GIVEN + const wrapper = promiseWithResolvers(); + const oldPromise = wrapper.promise; + + //WHEN + wrapper.reset(); + wrapper.resolve(42); + + //THEN + // Old promise reference should still resolve with the same value + await expect(oldPromise).resolves.toBe(42); + expect(oldPromise).toBe(wrapper.promise); + }); + + it("should handle catch", async () => { + //GIVEN + const { promise, reject } = promiseWithResolvers(); + const error = new Error("test error"); + + //WHEN + const caught = promise.catch(() => "recovered"); + reject(error); + + //THEN + await expect(caught).resolves.toBe("recovered"); + }); + + it("should call finally handler on resolution", async () => { + //GIVEN + const { promise, resolve } = promiseWithResolvers(); + const onFinally = vi.fn(); + + //WHEN + const final = promise.finally(onFinally); + resolve(42); + + //THEN + await expect(final).resolves.toBe(42); + expect(onFinally).toHaveBeenCalledOnce(); + }); + + it("should call finally handler on rejection", async () => { + //GIVEN + const { promise, reject } = promiseWithResolvers(); + const onFinally = vi.fn(); + const error = new Error("test error"); + + //WHEN + const final = promise.finally(onFinally); + reject(error); + + //THEN + await expect(final).rejects.toThrow("test error"); + expect(onFinally).toHaveBeenCalledOnce(); + }); + + it("should preserve rejection through finally", async () => { + //GIVEN + const { promise, reject } = promiseWithResolvers(); + const onFinally = vi.fn(); + const error = new Error("preserved error"); + + //WHEN + const final = promise.finally(onFinally); + reject(error); + + //THEN + await expect(final).rejects.toThrow("preserved error"); + expect(onFinally).toHaveBeenCalled(); + }); + }); }); diff --git a/frontend/src/ts/commandline/lists.ts b/frontend/src/ts/commandline/lists.ts index b7405613e..09894ad90 100644 --- a/frontend/src/ts/commandline/lists.ts +++ b/frontend/src/ts/commandline/lists.ts @@ -31,6 +31,7 @@ import * as FPSCounter from "../elements/fps-counter"; import { Command, CommandsSubgroup } from "./types"; import { buildCommandForConfigKey } from "./util"; import { CommandlineConfigMetadataObject } from "./commandline-metadata"; +import { isAuthAvailable, isAuthenticated, signOut } from "../firebase"; const challengesPromise = JSONData.getChallengeList(); challengesPromise @@ -366,6 +367,17 @@ export const commands: CommandsSubgroup = { window.open("https://discord.gg/monkeytype"); }, }, + { + id: "signOut", + display: "Sign out", + icon: "fa-sign-out-alt", + exec: (): void => { + void signOut(); + }, + available: () => { + return isAuthAvailable() && isAuthenticated(); + }, + }, ], }; diff --git a/frontend/src/ts/modals/register-captcha.ts b/frontend/src/ts/modals/register-captcha.ts index 55bf621c0..99b6e17c8 100644 --- a/frontend/src/ts/modals/register-captcha.ts +++ b/frontend/src/ts/modals/register-captcha.ts @@ -3,7 +3,11 @@ import AnimatedModal from "../utils/animated-modal"; import { promiseWithResolvers } from "../utils/misc"; import * as Notifications from "../elements/notifications"; -let { promise, resolve } = promiseWithResolvers(); +const { + promise, + resolve, + reset: resetPromise, +} = promiseWithResolvers(); export { promise }; @@ -20,7 +24,7 @@ export async function show(): Promise { await modal.show({ mode: "dialog", beforeAnimation: async (modal) => { - ({ promise, resolve } = promiseWithResolvers()); + resetPromise(); CaptchaController.reset("register"); CaptchaController.render( diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index 5d352af7b..24905cad8 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -46,6 +46,8 @@ import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; import { z } from "zod"; import * as TestState from "./test-state"; import { blurInputElement } from "../input/input-element"; +import * as ConnectionState from "../states/connection"; +import { currentQuote } from "./test-words"; let result: CompletedEvent; let maxChartVal: number; @@ -1006,6 +1008,11 @@ export async function update( } else { $("#result #watchVideoAdButton").removeClass("hidden"); } + + if (!ConnectionState.get()) { + ConnectionState.showOfflineBanner(); + } + updateWpmAndAcc(); updateConsistency(); updateTime(); @@ -1064,17 +1071,45 @@ export async function update( } else { $("main #result .stats").removeClass("hidden"); $("main #result .chart").removeClass("hidden"); - // $("main #result #resultWordsHistory").removeClass("hidden"); if (!isAuthenticated()) { $("main #result .loginTip").removeClass("hidden"); + $("main #result #rateQuoteButton").addClass("hidden"); + $("main #result #reportQuoteButton").addClass("hidden"); + } else { + updateRateQuote(currentQuote); + $("main #result #reportQuoteButton").removeClass("hidden"); } + $("main #result .stats .dailyLeaderboard").addClass("hidden"); $("main #result #showWordHistoryButton").removeClass("hidden"); $("main #result #watchReplayButton").removeClass("hidden"); $("main #result #saveScreenshotButton").removeClass("hidden"); } - TestConfig.hide(); + if (res.wpm === 0 && !difficultyFailed && res.testDuration >= 5) { + const roundedTime = Math.round(res.testDuration); + const messages = [ + `Congratulations. You just wasted ${roundedTime} seconds of your life by typing nothing. Be proud of yourself.`, + `Bravo! You've managed to waste ${roundedTime} seconds and accomplish exactly zero. A true productivity icon.`, + `That was ${roundedTime} seconds of absolutely legendary idleness. History will remember this moment.`, + `Wow, ${roundedTime} seconds of typing... nothing. Bold. Mysterious. Completely useless.`, + `Thank you for those ${roundedTime} seconds of utter nothingness. The keyboard needed the break.`, + `A breathtaking display of inactivity. ${roundedTime} seconds of absolutely nothing. Powerful.`, + `You just gave ${roundedTime} seconds of your life to the void. And the void says thanks.`, + `Stunning. ${roundedTime} seconds of intense... whatever that wasn't. Keep it up, champ.`, + `Is it performance art? A protest? Or just ${roundedTime} seconds of glorious nothing? We may never know.`, + `You typed nothing for ${roundedTime} seconds. And in that moment, you became legend.`, + ]; + + showConfetti(); + Notifications.add(Arrays.randomElementFromArray(messages), 0, { + customTitle: "Nice", + duration: 15, + important: true, + }); + } + + TestConfig.hide(); Focus.set(false); const canQuickRestart = canQuickRestartFn( diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 53fcdb878..28206df79 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -982,6 +982,8 @@ export async function finish(difficultyFailed = false): Promise { const completedEvent = structuredClone(ce) as CompletedEvent; + TestStats.setLastResult(structuredClone(completedEvent)); + ///////// completed event ready //afk check @@ -1011,9 +1013,11 @@ export async function finish(difficultyFailed = false): Promise { dontSave = true; } else if (afkDetected) { Notifications.add("Test invalid - AFK detected", 0); + TestStats.setInvalid(); dontSave = true; } else if (TestState.isRepeated) { Notifications.add("Test invalid - repeated", 0); + TestStats.setInvalid(); dontSave = true; } else if ( completedEvent.testDuration < 1 || @@ -1035,6 +1039,7 @@ export async function finish(difficultyFailed = false): Promise { (Config.mode === "zen" && completedEvent.testDuration < 15) ) { Notifications.add("Test invalid - too short", 0); + TestStats.setInvalid(); tooShort = true; dontSave = true; } else if ( @@ -1133,25 +1138,48 @@ export async function finish(difficultyFailed = false): Promise { ); Result.updateTodayTracker(); - if (!isAuthenticated()) { - $(".pageTest #result #rateQuoteButton").addClass("hidden"); - $(".pageTest #result #reportQuoteButton").addClass("hidden"); - void AnalyticsController.log("testCompletedNoLogin"); - if (!dontSave) notSignedInLastResult = completedEvent; - dontSave = true; + let savingResultPromise: ReturnType = + Promise.resolve(null); + const user = getAuthenticatedUser(); + if (user !== null) { + // logged in + if (dontSave) { + void AnalyticsController.log("testCompletedInvalid"); + } else { + TestStats.resetIncomplete(); + + if (completedEvent.testDuration > 122) { + completedEvent.chartData = "toolong"; + completedEvent.keySpacing = "toolong"; + completedEvent.keyDuration = "toolong"; + } + + if (!completedEvent.bailedOut) { + const challenge = ChallengeContoller.verify(completedEvent); + if (challenge !== null) completedEvent.challenge = challenge; + } + + completedEvent.uid = user.uid; + completedEvent.hash = objectHash(completedEvent); + + savingResultPromise = saveResult(completedEvent, false); + void savingResultPromise.then((response) => { + if (response && response.status === 200) { + void AnalyticsController.log("testCompleted"); + } + }); + } } else { - $(".pageTest #result #reportQuoteButton").removeClass("hidden"); + // logged out + void AnalyticsController.log("testCompletedNoLogin"); + if (!dontSave) { + // if its valid save it for later + notSignedInLastResult = completedEvent; + } + dontSave = true; } - $("#result .stats .dailyLeaderboard").addClass("hidden"); - - TestStats.setLastResult(structuredClone(completedEvent)); - - if (!ConnectionState.get()) { - ConnectionState.showOfflineBanner(); - } - - await Result.update( + const resultUpdatePromise = Result.update( completedEvent, difficultyFailed, failReason, @@ -1162,78 +1190,13 @@ export async function finish(difficultyFailed = false): Promise { dontSave, ); - if (completedEvent.chartData !== "toolong") { - // @ts-expect-error TODO: check if this is needed - delete completedEvent.chartData.unsmoothedRaw; - } - - if (completedEvent.testDuration > 122) { - completedEvent.chartData = "toolong"; - completedEvent.keySpacing = "toolong"; - completedEvent.keyDuration = "toolong"; - } - - if ( - completedEvent.wpm === 0 && - !difficultyFailed && - completedEvent.testDuration >= 5 - ) { - const roundedTime = Math.round(completedEvent.testDuration); - - const messages = [ - `Congratulations. You just wasted ${roundedTime} seconds of your life by typing nothing. Be proud of yourself.`, - `Bravo! You've managed to waste ${roundedTime} seconds and accomplish exactly zero. A true productivity icon.`, - `That was ${roundedTime} seconds of absolutely legendary idleness. History will remember this moment.`, - `Wow, ${roundedTime} seconds of typing... nothing. Bold. Mysterious. Completely useless.`, - `Thank you for those ${roundedTime} seconds of utter nothingness. The keyboard needed the break.`, - `A breathtaking display of inactivity. ${roundedTime} seconds of absolutely nothing. Powerful.`, - `You just gave ${roundedTime} seconds of your life to the void. And the void says thanks.`, - `Stunning. ${roundedTime} seconds of intense... whatever that wasn't. Keep it up, champ.`, - `Is it performance art? A protest? Or just ${roundedTime} seconds of glorious nothing? We may never know.`, - `You typed nothing for ${roundedTime} seconds. And in that moment, you became legend.`, - ]; - - Result.showConfetti(); - Notifications.add(Arrays.randomElementFromArray(messages), 0, { - customTitle: "Nice", - duration: 15, - important: true, - }); - } - - if (dontSave) { - void AnalyticsController.log("testCompletedInvalid"); - return; - } - - // because of the dont save check above, we know the user is signed in - // we check here again so that typescript doesnt complain - const user = getAuthenticatedUser(); - if (!user) { - return; - } - - // user is logged in - TestStats.resetIncomplete(); - - completedEvent.uid = user.uid; - - Result.updateRateQuote(TestWords.currentQuote); - - if (!completedEvent.bailedOut) { - const challenge = ChallengeContoller.verify(completedEvent); - if (challenge !== null) completedEvent.challenge = challenge; - } - - completedEvent.hash = objectHash(completedEvent); - - await saveResult(completedEvent, false); + await Promise.all([savingResultPromise, resultUpdatePromise]); } async function saveResult( completedEvent: CompletedEvent, isRetrying: boolean, -): Promise { +): Promise>> { AccountButton.loading(true); if (!TestState.savingEnabled) { @@ -1243,7 +1206,7 @@ async function saveResult( important: true, }); AccountButton.loading(false); - return; + return null; } if (!ConnectionState.get()) { @@ -1258,7 +1221,7 @@ async function saveResult( if (!isRetrying) { retrySaving.completedEvent = completedEvent; } - return; + return null; } const response = await Ape.results.add({ body: { result: completedEvent } }); @@ -1286,7 +1249,7 @@ async function saveResult( "Looks like your result data is using an incorrect schema. Please refresh the page to download the new update. If the problem persists, please contact support."; } Notifications.add("Failed to save result", -1, { response }); - return; + return response; } const data = response.body.data; @@ -1327,8 +1290,6 @@ async function saveResult( dataToSave.result = result; } - void AnalyticsController.log("testCompleted"); - if (data.isPb !== undefined && data.isPb) { //new pb const localPb = await DB.getLocalPB( @@ -1377,6 +1338,7 @@ async function saveResult( Notifications.add("Result saved", 1, { important: true }); } DB.saveLocalResult(dataToSave); + return response; } export function fail(reason: string): void { diff --git a/frontend/src/ts/test/test-state.ts b/frontend/src/ts/test/test-state.ts index 2cebf3531..1b5079d9d 100644 --- a/frontend/src/ts/test/test-state.ts +++ b/frontend/src/ts/test/test-state.ts @@ -67,16 +67,18 @@ export function setIsDirectionReversed(val: boolean): void { isDirectionReversed = val; } -let { promise: testRestartingPromise, resolve: restartingResolve } = - promiseWithResolvers(); +const { + promise: testRestartingPromise, + resolve: restartingResolve, + reset: resetTestRestarting, +} = promiseWithResolvers(); export { testRestartingPromise }; export function setTestRestarting(val: boolean): void { testRestarting = val; if (val) { - ({ promise: testRestartingPromise, resolve: restartingResolve } = - promiseWithResolvers()); + resetTestRestarting(); } else { restartingResolve(); } diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index f7422084f..86dba54a5 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -133,18 +133,17 @@ export function updateActiveElement( let previousActiveWordTop: number | null = null; if (initial === undefined) { - const previousActiveWord = wordsEl.querySelector( - ".active", - ) as HTMLElement; - if (direction === "forward") { - previousActiveWord.classList.add("typed"); - } else if (direction === "back") { - if (Config.mode === "zen") { - previousActiveWord.remove(); + const previousActiveWord = wordsEl.querySelector(".active"); + // in zen mode, because of the animation frame, previousActiveWord will be removed at this point, so check for null + if (previousActiveWord !== null) { + if (direction === "forward") { + previousActiveWord.classList.add("typed"); + } else if (direction === "back") { + // } + previousActiveWord.classList.remove("active"); + previousActiveWordTop = previousActiveWord.offsetTop; } - previousActiveWord.classList.remove("active"); - previousActiveWordTop = previousActiveWord.offsetTop; } const newActiveWord = getActiveWordElement(); @@ -1814,20 +1813,19 @@ export async function afterTestWordChange( // } else if (direction === "back") { if (Config.mode === "zen") { - const wordsChildren = [...(wordsEl.children ?? [])] as HTMLElement[]; - + // because we need to delete newline, beforenewline and afternewline elements which dont have wordindex attributes + // we need to do this loop thingy and delete all elements after the active word let deleteElements = false; - for (const child of wordsChildren) { - if ( - !deleteElements && - parseInt(child.getAttribute("data-wordindex") ?? "-1", 10) === - TestState.activeWordIndex - ) { - deleteElements = true; - continue; - } + for (const child of wordsEl.children) { if (deleteElements) { child.remove(); + continue; + } + const attr = child.getAttribute("data-wordindex"); + if (attr === null) continue; + const wordIndex = parseInt(attr, 10); + if (wordIndex === TestState.activeWordIndex) { + deleteElements = true; } } } diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index 105bb4e44..6e6ea8fca 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -597,19 +597,71 @@ export function applyReducedMotion(animationTime: number): number { /** * Creates a promise with resolvers. * This is useful for creating a promise that can be resolved or rejected from outside the promise itself. + * The returned promise reference stays constant even after reset() - it will always await the current internal promise. + * Note: Promise chains created via .then()/.catch()/.finally() will always follow the current internal promise state, even if created before reset(). */ export function promiseWithResolvers(): { resolve: (value: T) => void; reject: (reason?: unknown) => void; promise: Promise; + reset: () => void; } { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; + let innerResolve!: (value: T) => void; + let innerReject!: (reason?: unknown) => void; + let currentPromise = new Promise((res, rej) => { + innerResolve = res; + innerReject = rej; }); - return { resolve, reject, promise }; + + /** + * This was fully AI generated to make the reset function work. Black magic, but its unit-tested and works. + */ + + const promiseLike = { + // oxlint-disable-next-line no-thenable promise-function-async require-await + then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: + | ((reason: unknown) => TResult2 | PromiseLike) + | null, + ): Promise { + return currentPromise.then(onfulfilled, onrejected); + }, + // oxlint-disable-next-line promise-function-async + catch( + onrejected?: ((reason: unknown) => TResult | PromiseLike) | null, + ): Promise { + return currentPromise.catch(onrejected); + }, + // oxlint-disable-next-line promise-function-async + finally(onfinally?: (() => void) | null): Promise { + return currentPromise.finally(onfinally); + }, + [Symbol.toStringTag]: "Promise" as const, + }; + + const reset = (): void => { + currentPromise = new Promise((res, rej) => { + innerResolve = res; + innerReject = rej; + }); + }; + + // Wrapper functions that always call the current resolver/rejecter + const resolve = (value: T): void => { + innerResolve(value); + }; + + const reject = (reason?: unknown): void => { + innerReject(reason); + }; + + return { + resolve, + reject, + promise: promiseLike as Promise, + reset, + }; } /**