test: юнит-тесты на форматирование и разбивки крипто-портфеля + версия 1.1.6

Vitest для чистой логики: lib/format.ts (заодно поймал баг в
trimTrailingZeros на "-0.000") и расчёт разбивки по риску/монете/сети
на вкладке Crypto — вынес их из компонентов в отдельные тестируемые
функции. Тесты подключены в CI. Версия приложения обновлена до 1.1.6.
This commit is contained in:
ZProger
2026-09-09 00:52:27 +03:00
parent c3047c6d6a
commit f5c6591310
13 changed files with 1279 additions and 39 deletions

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { makeHolding } from "@/test/cryptoFixtures";
import { buildSlices } from "./CryptoAllocationBody";
describe("buildSlices (per-coin allocation)", () => {
it("keys each slice by asset_id, one per holding", () => {
const holdings = [makeHolding({ asset_id: 7, name: "Bitcoin", symbol: "BTC", value: "100" })];
const slices = buildSlices(holdings);
expect(slices).toEqual([{ key: "7", name: "Bitcoin", symbol: "BTC", amount: 100, color: "var(--series-1)" }]);
});
it("excludes unpriced and exactly-zero holdings", () => {
const holdings = [makeHolding({ value: null }), makeHolding({ value: "0" })];
expect(buildSlices(holdings)).toEqual([]);
});
it("sorts slices by amount descending", () => {
const holdings = [
makeHolding({ symbol: "SMALL", value: "10" }),
makeHolding({ symbol: "BIG", value: "90" }),
];
expect(buildSlices(holdings).map((s) => s.symbol)).toEqual(["BIG", "SMALL"]);
});
it("folds every coin past the top 7 into one 'Other' slice", () => {
const holdings = Array.from({ length: 9 }, (_, i) => makeHolding({ symbol: `C${i}`, value: String(9 - i) }));
const slices = buildSlices(holdings);
expect(slices).toHaveLength(8);
const other = slices.find((s) => s.key === "other")!;
expect(other.name).toBe("Other");
expect(other.symbol).toBeNull();
// The two smallest holdings: value 2 and value 1.
expect(other.amount).toBe(3);
expect(other.color).toBe("var(--series-other)");
});
it("returns nothing at all when every holding is unpriced", () => {
expect(buildSlices([makeHolding({ value: null })])).toEqual([]);
});
});

View File

@@ -26,7 +26,7 @@ const SERIES_COLORS = [
const OTHER_COLOR = "var(--series-other)";
const MAX_SLICES = SERIES_COLORS.length;
interface Slice {
export interface Slice {
key: string;
name: string;
symbol: string | null;
@@ -34,7 +34,9 @@ interface Slice {
color: string;
}
function buildSlices(holdings: CryptoHolding[]): Slice[] {
// Exported for direct unit testing (see CryptoAllocationBody.test.ts) —
// the donut/table JSX below isn't worth rendering just to check the math.
export function buildSlices(holdings: CryptoHolding[]): Slice[] {
const priced = holdings
.filter((h) => h.value !== null && Number(h.value) > 0)
.map((h) => ({ key: String(h.asset_id), name: h.name, symbol: h.symbol, amount: Number(h.value) }))

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { makeHolding } from "@/test/cryptoFixtures";
import { buildSlices } from "./CryptoNetworkAllocationBody";
const UNSET = "No network set";
describe("buildSlices (network allocation)", () => {
it("groups holdings by network, summing their value", () => {
const holdings = [
makeHolding({ network: "Ethereum", value: "100" }),
makeHolding({ network: "Ethereum", value: "50" }),
makeHolding({ network: "Tron", value: "30" }),
];
const slices = buildSlices(holdings, UNSET);
const byLabel = Object.fromEntries(slices.map((s) => [s.label, s.amount]));
expect(byLabel["Ethereum"]).toBe(150);
expect(byLabel["Tron"]).toBe(30);
});
it("folds holdings with no network into one unset bucket instead of dropping them", () => {
const holdings = [makeHolding({ network: null, value: "40" }), makeHolding({ network: null, value: "10" })];
const slices = buildSlices(holdings, UNSET);
expect(slices).toHaveLength(1);
expect(slices[0].label).toBe(UNSET);
expect(slices[0].amount).toBe(50);
});
it("excludes unpriced holdings entirely", () => {
const holdings = [makeHolding({ network: "Ethereum", value: null })];
expect(buildSlices(holdings, UNSET)).toEqual([]);
});
it("sorts slices by amount descending", () => {
const holdings = [
makeHolding({ network: "Small", value: "10" }),
makeHolding({ network: "Big", value: "90" }),
];
const slices = buildSlices(holdings, UNSET);
expect(slices.map((s) => s.label)).toEqual(["Big", "Small"]);
});
it("assigns each of the first 7 slices its own distinct color", () => {
const holdings = Array.from({ length: 7 }, (_, i) => makeHolding({ network: `Net${i}`, value: String(10 - i) }));
const slices = buildSlices(holdings, UNSET);
expect(slices).toHaveLength(7);
expect(new Set(slices.map((s) => s.color)).size).toBe(7);
});
it("folds every network past the top 7 into one '__other__' slice", () => {
// 9 distinct networks — 7 keep their own slice, 2 fold into "other".
const holdings = Array.from({ length: 9 }, (_, i) => makeHolding({ network: `Net${i}`, value: String(9 - i) }));
const slices = buildSlices(holdings, UNSET);
expect(slices).toHaveLength(8); // 7 top + 1 "other"
const other = slices.find((s) => s.key === "__other__")!;
expect(other).toBeDefined();
// The two smallest: Net7 (value 2) and Net8 (value 1).
expect(other.amount).toBe(3);
});
});

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { makeHolding } from "@/test/cryptoFixtures";
import { computeRiskTiers } from "./CryptoRiskAllocationBody";
describe("computeRiskTiers", () => {
it("always returns exactly the three tiers, in low/medium/high order", () => {
const tiers = computeRiskTiers([]);
expect(tiers.map((t) => t.level)).toEqual(["low", "medium", "high"]);
});
it("splits total portfolio value by risk_level", () => {
const holdings = [
makeHolding({ symbol: "BTC", risk_level: "low", value: "600" }),
makeHolding({ symbol: "ETH", risk_level: "medium", value: "300" }),
makeHolding({ symbol: "SHIB", risk_level: "high", value: "100" }),
];
const tiers = computeRiskTiers(holdings);
const byLevel = Object.fromEntries(tiers.map((t) => [t.level, t]));
expect(byLevel.low.value).toBe(600);
expect(byLevel.low.percent).toBeCloseTo(60);
expect(byLevel.medium.percent).toBeCloseTo(30);
expect(byLevel.high.percent).toBeCloseTo(10);
});
it("sums multiple holdings in the same tier instead of overwriting", () => {
const holdings = [
makeHolding({ symbol: "SHIB", risk_level: "high", value: "100" }),
makeHolding({ symbol: "PEPE", risk_level: "high", value: "50" }),
];
const high = computeRiskTiers(holdings).find((t) => t.level === "high")!;
expect(high.value).toBe(150);
expect(high.items).toHaveLength(2);
});
it("excludes unpriced holdings from every percentage, not just their own tier", () => {
const holdings = [
makeHolding({ symbol: "BTC", risk_level: "low", value: "100" }),
makeHolding({ symbol: "UNPRICED", risk_level: "low", value: null }),
];
const low = computeRiskTiers(holdings).find((t) => t.level === "low")!;
expect(low.value).toBe(100);
expect(low.percent).toBe(100);
expect(low.items).toHaveLength(1);
});
it("sorts a tier's items by value descending", () => {
const holdings = [
makeHolding({ symbol: "SMALL", risk_level: "high", value: "10" }),
makeHolding({ symbol: "BIG", risk_level: "high", value: "90" }),
];
const high = computeRiskTiers(holdings).find((t) => t.level === "high")!;
expect(high.items.map((i) => i.symbol)).toEqual(["BIG", "SMALL"]);
});
it("computes each item's percent of its own tier, not of the whole portfolio", () => {
const holdings = [
makeHolding({ symbol: "BIG", risk_level: "high", value: "90" }),
makeHolding({ symbol: "SMALL", risk_level: "high", value: "10" }),
makeHolding({ symbol: "OTHER_TIER", risk_level: "low", value: "900" }),
];
const high = computeRiskTiers(holdings).find((t) => t.level === "high")!;
// 90/100 and 10/100 of the *high tier's* 100, not of the 1000 total.
expect(high.items.find((i) => i.symbol === "BIG")!.percent).toBeCloseTo(90);
expect(high.items.find((i) => i.symbol === "SMALL")!.percent).toBeCloseTo(10);
});
it("leaves an empty tier at 0%, not NaN, when nothing is held in it", () => {
const holdings = [makeHolding({ risk_level: "high", value: "100" })];
const low = computeRiskTiers(holdings).find((t) => t.level === "low")!;
expect(low.value).toBe(0);
expect(low.percent).toBe(0);
expect(low.items).toEqual([]);
});
it("leaves every tier at 0% when nothing is priced at all, instead of dividing by zero", () => {
const holdings = [makeHolding({ risk_level: "high", value: null })];
for (const tier of computeRiskTiers(holdings)) {
expect(tier.percent).toBe(0);
expect(Number.isNaN(tier.percent)).toBe(false);
}
});
});

View File

@@ -8,16 +8,60 @@ interface CryptoRiskAllocationBodyProps {
hidden: boolean;
}
// Same status-color convention as NetWorthPage's own RiskAllocationCard
// (low reads as "good", high as "critical") — not a categorical hue, so
// hardcoded here rather than threaded through from the backend.
// Same traffic-light colors as the holdings table's own RiskLevelPicker
// (green/orange/red) — kept in sync so a coin's risk reads identically in
// both places on the Crypto tab. --series-4 is the dataviz skill's
// validated yellow/orange slot.
const TIER_COLOR: Record<RiskLevel, string> = {
low: "var(--success)",
medium: "var(--text-muted)",
medium: "var(--series-4)",
high: "var(--danger)",
};
const RISK_LEVELS: RiskLevel[] = ["low", "medium", "high"];
export interface RiskTierItem {
symbol: string;
name: string;
value: number;
percent: number;
}
export interface RiskTier {
level: RiskLevel;
value: number;
percent: number;
items: RiskTierItem[];
}
/** Groups priced holdings into the three RiskLevel tiers with each tier's
* share of total portfolio value, and each item's share within its own
* tier — pulled out of the component so it's testable without rendering
* (see format.test.ts-style coverage in CryptoRiskAllocationBody.test.ts). */
export function computeRiskTiers(holdings: CryptoHolding[]): RiskTier[] {
// Unpriced holdings (CoinGecko never successfully priced them yet) can't
// contribute a % of portfolio value — same "best data on hand" principle
// the rest of the Crypto tab already follows for a null value/price.
const priced = holdings.filter((h) => h.value !== null);
const totalValue = priced.reduce((sum, h) => sum + Number(h.value), 0);
return RISK_LEVELS.map((level) => {
const items = priced
.filter((h) => h.risk_level === level)
.map((h) => ({ symbol: h.symbol, name: h.name, value: Number(h.value) }))
.sort((a, b) => b.value - a.value);
const tierValue = items.reduce((sum, item) => sum + item.value, 0);
return {
level,
value: tierValue,
percent: totalValue > 0 ? (tierValue / totalValue) * 100 : 0,
items: items.map((item) => ({
...item,
percent: tierValue > 0 ? (item.value / tierValue) * 100 : 0,
})),
};
});
}
/** The "Risk levels" tab's content inside CryptoOverviewCard — same
* three-tier layout as NetWorthPage's own RiskAllocationCard, but scoped to
* crypto holdings only (not the whole net worth), so "am I overweight on
@@ -34,28 +78,7 @@ export function CryptoRiskAllocationBody({ holdings, isLoading, hidden }: Crypto
return <p className="py-10 text-center text-sm text-text-muted">{t("crypto.empty")}</p>;
}
// Unpriced holdings (CoinGecko never successfully priced them yet) can't
// contribute a % of portfolio value — same "best data on hand" principle
// the rest of the Crypto tab already follows for a null value/price.
const priced = holdings.filter((h) => h.value !== null);
const totalValue = priced.reduce((sum, h) => sum + Number(h.value), 0);
const tiers = RISK_LEVELS.map((level) => {
const items = priced
.filter((h) => h.risk_level === level)
.map((h) => ({ symbol: h.symbol, name: h.name, value: Number(h.value) }))
.sort((a, b) => b.value - a.value);
const tierValue = items.reduce((sum, item) => sum + item.value, 0);
return {
level,
value: tierValue,
percent: totalValue > 0 ? (tierValue / totalValue) * 100 : 0,
items: items.map((item) => ({
...item,
percent: tierValue > 0 ? (item.value / tierValue) * 100 : 0,
})),
};
});
const tiers = computeRiskTiers(holdings);
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">

View File

@@ -0,0 +1,155 @@
import { beforeEach, describe, expect, it } from "vitest";
import { setLanguage } from "@/lib/i18n";
import {
formatCryptoAmount,
formatCurrency,
formatSignedCurrency,
formatTransactionDate,
getIntlLocale,
getMonthLabels,
maskAmount,
pluralizeRu,
trimTrailingZeros,
} from "@/lib/format";
describe("getIntlLocale", () => {
it("maps ru to ru-RU and en to en-US", () => {
expect(getIntlLocale("ru")).toBe("ru-RU");
expect(getIntlLocale("en")).toBe("en-US");
});
});
describe("formatCurrency", () => {
beforeEach(() => setLanguage("en"));
it("keeps unambiguous currency symbols compact, not spelled out", () => {
// The exact regression this suite exists to catch: currencyDisplay
// "code" would turn this into "USD 614" — see lib/format.ts's
// createCurrencyFormatter docstring and the PR #18 review that flagged it.
expect(formatCurrency(614, "USD")).toBe("$614");
});
it("drops the region prefix on ambiguous symbols (CNY, HKD) instead of spelling out the code", () => {
const cny = formatCurrency(614, "CNY");
const hkd = formatCurrency(614, "HKD");
expect(cny).not.toContain("CN¥");
expect(cny).not.toContain("CNY");
expect(hkd).not.toContain("HK$");
expect(hkd).not.toContain("HKD");
});
it("rounds to whole units", () => {
expect(formatCurrency(614.5, "USD")).toBe("$615");
expect(formatCurrency(614.4, "USD")).toBe("$614");
});
it("accepts a numeric string the same as a number", () => {
expect(formatCurrency("614", "USD")).toBe(formatCurrency(614, "USD"));
});
});
describe("formatCryptoAmount", () => {
beforeEach(() => setLanguage("en"));
it("uses 2 decimals once the value reaches 1", () => {
expect(formatCryptoAmount(1234.5678, "USD")).toBe("$1,234.57");
});
it("uses 2 decimals for exactly zero", () => {
expect(formatCryptoAmount(0, "USD")).toBe("$0.00");
});
it("scales precision for sub-cent values so they don't round away to $0.00", () => {
// 8 leading zeros after the decimal point + 4 digits of real precision.
expect(formatCryptoAmount(0.000000006894, "USD")).toBe("$0.000000006894");
});
});
describe("formatSignedCurrency", () => {
beforeEach(() => setLanguage("en"));
it("prefixes a plus sign on positive amounts", () => {
expect(formatSignedCurrency(100, "USD")).toBe("+$100");
});
it("leaves the formatter's own minus sign on negative amounts, no double sign", () => {
expect(formatSignedCurrency(-100, "USD")).toBe("-$100");
});
it("adds no sign for zero", () => {
expect(formatSignedCurrency(0, "USD")).toBe("$0");
});
});
describe("maskAmount", () => {
it("replaces the formatted value when hidden", () => {
expect(maskAmount("$1,234", true)).toBe("••••");
});
it("passes the formatted value through when not hidden", () => {
expect(maskAmount("$1,234", false)).toBe("$1,234");
});
});
describe("trimTrailingZeros", () => {
it("strips a wei-level all-zero fractional part down to the integer", () => {
expect(trimTrailingZeros("3.000000000000000000")).toBe("3");
});
it("trims only the trailing zeros, keeping real precision", () => {
expect(trimTrailingZeros("3.140000")).toBe("3.14");
});
it("leaves a value with no decimal point untouched", () => {
expect(trimTrailingZeros("42")).toBe("42");
});
it("collapses an all-zero fractional part to a bare 0, not empty or a dangling minus", () => {
expect(trimTrailingZeros("0.000")).toBe("0");
expect(trimTrailingZeros("-0.000")).toBe("0");
});
});
describe("getMonthLabels", () => {
it("returns 12 labels for each language", () => {
expect(getMonthLabels("en")).toHaveLength(12);
expect(getMonthLabels("ru")).toHaveLength(12);
});
it("starts January in both languages", () => {
expect(getMonthLabels("en")[0]).toBe("Jan");
expect(getMonthLabels("ru")[0]).toBe("Янв");
});
});
describe("formatTransactionDate", () => {
it("omits the year by default", () => {
expect(formatTransactionDate("2026-08-29", false)).toBe(getIntlLocale("en") === "en-US" ? "Aug 29" : "Aug 29");
});
it("includes the year when asked", () => {
expect(formatTransactionDate("2026-08-29", true)).toContain("2026");
});
});
describe("pluralizeRu", () => {
it("picks the singular form for 1 (and 21, 31, ... but not 11)", () => {
expect(pluralizeRu(1, "актив", "актива", "активов")).toBe("актив");
expect(pluralizeRu(21, "актив", "актива", "активов")).toBe("актив");
expect(pluralizeRu(11, "актив", "актива", "активов")).not.toBe("актив");
});
it("picks the few form for 2-4 (and 22-24, but not 12-14)", () => {
expect(pluralizeRu(2, "актив", "актива", "активов")).toBe("актива");
expect(pluralizeRu(4, "актив", "актива", "активов")).toBe("актива");
expect(pluralizeRu(22, "актив", "актива", "активов")).toBe("актива");
expect(pluralizeRu(12, "актив", "актива", "активов")).toBe("активов");
});
it("picks the many form for 0, 5-20, and 11-14", () => {
expect(pluralizeRu(0, "актив", "актива", "активов")).toBe("активов");
expect(pluralizeRu(5, "актив", "актива", "активов")).toBe("активов");
expect(pluralizeRu(11, "актив", "актива", "активов")).toBe("активов");
expect(pluralizeRu(14, "актив", "актива", "активов")).toBe("активов");
});
});

View File

@@ -82,7 +82,7 @@ export function maskAmount(formatted: string, hidden: boolean): string {
export function trimTrailingZeros(value: string): string {
if (!value.includes(".")) return value;
const trimmed = value.replace(/0+$/, "").replace(/\.$/, "");
return trimmed === "" || trimmed === "-" ? "0" : trimmed;
return trimmed === "" || trimmed === "-" || trimmed === "-0" ? "0" : trimmed;
}
const MONTH_LABELS_EN = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;

View File

@@ -0,0 +1,33 @@
import type { CryptoHolding } from "@/types";
let nextAssetId = 1;
/** Minimal valid CryptoHolding for allocation-math tests — every field the
* type requires, with sane defaults so a test only has to override what it
* actually cares about (symbol, value, risk_level, network, ...). */
export function makeHolding(overrides: Partial<CryptoHolding> = {}): CryptoHolding {
const id = overrides.asset_id ?? nextAssetId++;
return {
asset_id: id,
portfolio_id: 1,
coingecko_id: `coin-${id}`,
symbol: `C${id}`,
name: `Coin ${id}`,
thumb_url: null,
risk_level: "high",
network: null,
quantity: "1",
avg_buy_price: "1",
current_price: "1",
price_change_1h: null,
price_change_24h: null,
price_change_7d: null,
price_change_30d: null,
price_change_1y: null,
value: "100",
cost_basis: "100",
profit_loss: "0",
profit_loss_percent: 0,
...overrides,
};
}