mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-12-28 10:59:46 +08:00
Merge branch 'master' into fix/raceCO-zen
This commit is contained in:
commit
2c53b205f5
6 changed files with 326 additions and 25 deletions
|
|
@ -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<number>();
|
||||
|
||||
//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<number>();
|
||||
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<number>();
|
||||
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<number>();
|
||||
|
||||
//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<number>();
|
||||
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<number>();
|
||||
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<number>();
|
||||
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<ComplexType>();
|
||||
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<number>();
|
||||
|
||||
//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<number>();
|
||||
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<string>();
|
||||
|
||||
//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<number>();
|
||||
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<number>();
|
||||
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<number>();
|
||||
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<number>();
|
||||
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<number>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string | undefined>();
|
||||
const {
|
||||
promise,
|
||||
resolve,
|
||||
reset: resetPromise,
|
||||
} = promiseWithResolvers<string | undefined>();
|
||||
|
||||
export { promise };
|
||||
|
||||
|
|
@ -20,7 +24,7 @@ export async function show(): Promise<void> {
|
|||
await modal.show({
|
||||
mode: "dialog",
|
||||
beforeAnimation: async (modal) => {
|
||||
({ promise, resolve } = promiseWithResolvers<string | undefined>());
|
||||
resetPromise();
|
||||
CaptchaController.reset("register");
|
||||
|
||||
CaptchaController.render(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1816,20 +1816,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T = void>(): {
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
promise: Promise<T>;
|
||||
reset: () => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
let innerResolve!: (value: T) => void;
|
||||
let innerReject!: (reason?: unknown) => void;
|
||||
let currentPromise = new Promise<T>((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<TResult1 = T, TResult2 = never>(
|
||||
onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
|
||||
onrejected?:
|
||||
| ((reason: unknown) => TResult2 | PromiseLike<TResult2>)
|
||||
| null,
|
||||
): Promise<TResult1 | TResult2> {
|
||||
return currentPromise.then(onfulfilled, onrejected);
|
||||
},
|
||||
// oxlint-disable-next-line promise-function-async
|
||||
catch<TResult = never>(
|
||||
onrejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null,
|
||||
): Promise<T | TResult> {
|
||||
return currentPromise.catch(onrejected);
|
||||
},
|
||||
// oxlint-disable-next-line promise-function-async
|
||||
finally(onfinally?: (() => void) | null): Promise<T> {
|
||||
return currentPromise.finally(onfinally);
|
||||
},
|
||||
[Symbol.toStringTag]: "Promise" as const,
|
||||
};
|
||||
|
||||
const reset = (): void => {
|
||||
currentPromise = new Promise<T>((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<T>,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue