From b32693926de3f86b6e317347a887896cecb570a8 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 20 Dec 2025 21:20:40 +0100 Subject: [PATCH] reset --- frontend/__tests__/utils/misc.spec.ts | 230 ++++++++++++++++++++- frontend/src/ts/modals/register-captcha.ts | 8 +- frontend/src/ts/test/test-state.ts | 10 +- frontend/src/ts/utils/misc.ts | 63 +++++- 4 files changed, 297 insertions(+), 14 deletions(-) diff --git a/frontend/__tests__/utils/misc.spec.ts b/frontend/__tests__/utils/misc.spec.ts index 893282946..625167e6a 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,225 @@ 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 wrapper = promiseWithResolvers(); + wrapper.resolve(42); + await expect(wrapper.promise).resolves.toBe(42); + + //WHEN + wrapper.reset(); + wrapper.resolve(100); + + //THEN + await expect(wrapper.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 maintain independent state for multiple instances", async () => { + //GIVEN + const first = promiseWithResolvers(); + const second = promiseWithResolvers(); + + //WHEN + first.resolve(1); + second.resolve(2); + + //THEN + await expect(first.promise).resolves.toBe(1); + await expect(second.promise).resolves.toBe(2); + }); + + it("should handle reset before resolve", async () => { + //GIVEN + const wrapper = promiseWithResolvers(); + const oldPromise = wrapper.promise; + + //WHEN + wrapper.reset(); + wrapper.resolve(42); + + //THEN + await expect(wrapper.promise).resolves.toBe(42); + // Old promise should never resolve + const race = await Promise.race([ + oldPromise.then(() => "old"), + Promise.resolve("timeout"), + ]); + expect(race).toBe("timeout"); + }); + + it("should work with Promise.all", async () => { + //GIVEN + const first = promiseWithResolvers(); + const second = promiseWithResolvers(); + const third = promiseWithResolvers(); + + //WHEN + first.resolve(1); + second.resolve(2); + third.resolve(3); + + //THEN + await expect( + Promise.all([first.promise, second.promise, third.promise]), + ).resolves.toEqual([1, 2, 3]); + }); + + it("should work with Promise.race", async () => { + //GIVEN + const first = promiseWithResolvers(); + const second = promiseWithResolvers(); + + //WHEN + first.resolve("first"); + setTimeout(() => second.resolve("second"), 100); + + //THEN + await expect(Promise.race([first.promise, second.promise])).resolves.toBe( + "first", + ); + }); + }); }); 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/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/utils/misc.ts b/frontend/src/ts/utils/misc.ts index 105bb4e44..f26218076 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -597,19 +597,70 @@ 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. */ 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, + }; } /**