refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
@@ -1,85 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { bindIconAnimation } from "./bind-icon-animation";
afterEach(() => vi.restoreAllMocks());
it("finishes every delayed part after leaving and does not restart on quick re-entry", async () => {
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: true,
} as MediaQueryList);
const { container } = render(
<button type="button">
<svg aria-hidden="true" ref={bindIconAnimation} />
Category
</button>,
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role.
const icon = container.querySelector("svg");
if (!icon) throw new Error("Missing icon");
const target = screen.getByRole("button");
let finishFirst!: () => void;
let finishLast!: () => void;
const getAnimations = vi.fn(() => {
const first = Promise.withResolvers<void>();
const last = Promise.withResolvers<void>();
finishFirst = first.resolve;
finishLast = last.resolve;
return [{ finished: first.promise }, { finished: last.promise }];
});
Object.defineProperty(icon, "getAnimations", { value: getAnimations });
fireEvent.mouseEnter(target);
fireEvent.mouseLeave(target);
expect(icon).toHaveAttribute("data-icon-animating");
finishFirst();
await Promise.resolve();
expect(icon).toHaveAttribute("data-icon-animating");
fireEvent.mouseEnter(target);
expect(getAnimations).toHaveBeenCalledTimes(1);
finishLast();
await Promise.resolve();
expect(icon).toHaveAttribute("data-icon-animating");
fireEvent.mouseLeave(target);
await waitFor(() => expect(icon).not.toHaveAttribute("data-icon-animating"));
fireEvent.mouseEnter(target);
expect(getAnimations).toHaveBeenCalledTimes(2);
});
it("keeps playing after keyboard blur and releases cancelled animations", async () => {
const { container, unmount } = render(
<button type="button">
<svg aria-hidden="true" ref={bindIconAnimation} />
Category
</button>,
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role.
const icon = container.querySelector("svg");
if (!icon) throw new Error("Missing icon");
const target = screen.getByRole("button");
// JSDOM does not model the browser keyboard-focus modality.
vi.spyOn(target, "matches").mockImplementation(
selector => selector === ":focus-visible",
);
let cancel!: () => void;
Object.defineProperty(icon, "getAnimations", {
value: () => [
{
finished: new Promise<void>((_resolve, reject) => {
cancel = () => reject(new Error("cancelled"));
}),
},
],
});
target.focus();
expect(icon).toHaveAttribute("data-icon-animating");
target.blur();
expect(icon).toHaveAttribute("data-icon-animating");
cancel();
await waitFor(() => expect(icon).not.toHaveAttribute("data-icon-animating"));
target.focus();
unmount();
cancel();
await Promise.resolve();
expect(icon).not.toHaveAttribute("data-icon-animating");
});
-68
View File
@@ -1,68 +0,0 @@
// React ref cleanup keeps native animation lifetimes tied to the mounted icon.
const bindIconAnimation = (icon: null | SVGSVGElement) => {
if (!icon) return;
const target = icon.closest<HTMLElement>("a, button") ?? icon.parentElement;
if (!target) return;
let isHovered = false;
let isFocused = false;
let isRunning = false;
let isDisposed = false;
const release = () => {
if (!isRunning && !isHovered && !isFocused) {
delete icon.dataset.iconAnimating;
}
};
const start = () => {
if (Object.hasOwn(icon.dataset, "iconAnimating")) return;
icon.dataset.iconAnimating = "";
isRunning = true;
// Includes staggered/delayed children and finite iterations, without timers.
const animations = icon.getAnimations({ subtree: true });
void Promise.allSettled(
animations.map(animation => animation.finished),
).then(() => {
if (isDisposed) return;
isRunning = false;
release();
});
};
const enter = () => {
if (!matchMedia("(hover: hover)").matches) return;
isHovered = true;
start();
};
const leave = () => {
isHovered = false;
release();
};
const focus = () => {
isFocused
= target.matches(":focus-visible")
|| !!target.querySelector(":focus-visible");
if (isFocused) start();
};
const blur = (event: FocusEvent) => {
if (
event.relatedTarget instanceof Node
&& target.contains(event.relatedTarget)
)
return;
isFocused = false;
release();
};
target.addEventListener("mouseenter", enter);
target.addEventListener("mouseleave", leave);
target.addEventListener("focusin", focus);
target.addEventListener("focusout", blur);
return () => {
isDisposed = true;
target.removeEventListener("mouseenter", enter);
target.removeEventListener("mouseleave", leave);
target.removeEventListener("focusin", focus);
target.removeEventListener("focusout", blur);
delete icon.dataset.iconAnimating;
};
};
export { bindIconAnimation };
+3
View File
@@ -0,0 +1,3 @@
import { z } from "zod";
const calendarDate = z.iso.date();
export { calendarDate };
+12
View File
@@ -0,0 +1,12 @@
import { calendarDate } from "./calendar.schema";
function isValidPeriod(from: null | string, to: null | string) {
return (
[from, to].every(
date => date === null || calendarDate.safeParse(date).success,
)
&& (!from || !to || from <= to)
);
}
export { isValidPeriod };
+11 -4
View File
@@ -22,11 +22,18 @@ describe("shared display helpers", () => {
);
expect(formatMoney({ currency: "RUB", value: "10" })).toBe("10 ₽");
expect(formatMoney({ currency: "USD", value: "0.5" })).toBe("0,5 USD");
expect(formatMoney({ currency: "RUB", value: "0001234.50" })).toBe("0 001 234,50 ₽");
expect(formatMoney({ currency: "RUB", value: "123456789012345678901234567890.01" })).toBe(
"123 456 789 012 345 678 901 234 567 890,01 ₽",
expect(formatMoney({ currency: "RUB", value: "0001234.50" })).toBe(
"0 001 234,50 ₽",
);
expect(formatMoney({ currency: "RUB", value: "1.5" }, { withZeroCents: true })).toBe("1,50 ₽");
expect(
formatMoney({
currency: "RUB",
value: "123456789012345678901234567890.01",
}),
).toBe("123 456 789 012 345 678 901 234 567 890,01 ₽");
expect(
formatMoney({ currency: "RUB", value: "1.5" }, { withZeroCents: true }),
).toBe("1,50 ₽");
expect(formatMoney(null)).toBe("—");
});
+32 -6
View File
@@ -4,6 +4,7 @@ const CENTS_PRECISION = 2;
type FormatMoneyOptions = {
withZeroCents?: boolean;
};
type Money = { currency: string; value: string };
function formatDate(value: string): string {
@@ -21,23 +22,40 @@ function formatDecimal(value: string): string {
const digits = sign ? integer.slice(1) : integer;
const groupSize = 3;
const groups: string[] = [];
for (let end = digits.length; end > 0; end -= groupSize) {
groups.push(digits.slice(Math.max(0, end - groupSize), end));
}
const grouped = groups.toReversed().join(" ");
const suffix = fraction ? `,${fraction}` : "";
return `${sign}${grouped}${suffix}`;
}
function formatMoney(
money: Money | null | undefined,
money: Money
| null
| undefined,
{ withZeroCents = false }: FormatMoneyOptions = {},
): string {
if (!money) return "—";
const [integerPart, fractionPart = ""] = money.value.split(".", DECIMAL_PART_COUNT);
const normalizedFraction = withZeroCents ? fractionPart.padEnd(CENTS_PRECISION, "0") : fractionPart;
const decimal = normalizedFraction ? `${integerPart}.${normalizedFraction}` : integerPart;
const [integerPart, fractionPart = ""] = money.value.split(
".",
DECIMAL_PART_COUNT,
);
const normalizedFraction = withZeroCents
? fractionPart.padEnd(CENTS_PRECISION, "0")
: fractionPart;
const decimal = normalizedFraction
? `${integerPart}.${normalizedFraction}`
: integerPart;
const currency = money.currency === "RUB" ? "₽" : money.currency;
return `${formatDecimal(decimal)} ${currency}`;
}
@@ -51,14 +69,22 @@ function formatSnapshotTime(value: string): string {
const logoSize = 160;
function instrumentLogoUrl(logoName: null | string | undefined): null | string {
function formatChartValue(value: number) {
return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 4 }).format(
value,
);
}
function instrumentLogoUrl(logoName?: null | string): null | string {
const stem = logoName?.trim().replace(/\.png$/i, "");
return stem
? `https://invest-brands.cdn-tinkoff.ru/${stem}x${logoSize}.png`
: /* eslint-disable-line unicorn/no-null -- The existing formatter contract uses null for unavailable data. */ null;
: null; // eslint-disable-line unicorn/no-null -- The existing formatter contract uses null for unavailable data.
}
export {
formatChartValue,
formatDate,
formatDecimal,
formatMoney,
+4 -1
View File
@@ -1,8 +1,11 @@
export { bindIconAnimation } from "./bind-icon-animation";
export { isValidPeriod } from "./calendar";
export { calendarDate } from "./calendar.schema";
export {
formatChartValue,
formatDate,
formatDecimal,
formatMoney,
formatSnapshotTime,
instrumentLogoUrl,
} from "./display";
export { useImageFallback } from "./useImageFallback";
+10
View File
@@ -0,0 +1,10 @@
import { useState } from "react";
function useImageFallback() {
const [failed, setFailed] = useState(false);
const onError = () => setFailed(true);
return { failed, onError };
}
export { useImageFallback };