mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 11:06:40 +07:00
test: юнит-тесты на форматирование и разбивки крипто-портфеля + версия 1.1.6
Vitest для чистой логики: lib/format.ts (заодно поймал баг в trimTrailingZeros на "-0.000") и расчёт разбивки по риску/монете/сети на вкладке Crypto — вынес их из компонентов в отдельные тестируемые функции. Тесты подключены в CI. Версия приложения обновлена до 1.1.6.
This commit is contained in:
40
frontend/src/components/crypto/CryptoAllocationBody.test.ts
Normal file
40
frontend/src/components/crypto/CryptoAllocationBody.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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) }))
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
155
frontend/src/lib/format.test.ts
Normal file
155
frontend/src/lib/format.test.ts
Normal 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("активов");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
33
frontend/src/test/cryptoFixtures.ts
Normal file
33
frontend/src/test/cryptoFixtures.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user