refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -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");
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { z } from "zod";
|
||||
const calendarDate = z.iso.date();
|
||||
export { calendarDate };
|
||||
@@ -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 };
|
||||
@@ -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("—");
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useState } from "react";
|
||||
|
||||
function useImageFallback() {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const onError = () => setFailed(true);
|
||||
|
||||
return { failed, onError };
|
||||
}
|
||||
|
||||
export { useImageFallback };
|
||||
Reference in New Issue
Block a user