Files
frontend/src/pages/account/model/positions.test.ts
T
codex-bot 3075081df1
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 20s
Frontend verify / verify (push) Failing after 6m36s
feat: replace account page accordion with tabs and show open position result (#112)
2026-10-26 20:30:35 +03:00

312 lines
8.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import {
formatPositionReturnPercent,
toAccountPagePositionSections,
toUnrealizedReturns,
} from "./positions";
import { portfolio, position, second } from "./positions.fixtures";
function makePosition(
overrides: Partial<Portfolio["positions"][number]>,
): Portfolio["positions"][number] {
return { ...position, ...overrides };
}
function sections(positions: Portfolio["positions"]) {
return toAccountPagePositionSections({
...portfolio,
positions,
});
}
describe("account position sections", () => {
it("excludes currency instruments only from display, preserving order and share denominators", () => {
const otherTypes = [
"OPTION",
"OTHER",
"FUTURE",
"STRUCTURED_NOTE",
"DIGITAL_FINANCIAL_ASSET",
"FUND",
] as const;
const snapshot: Portfolio = {
...portfolio,
cashBalances: [{ currency: "RUB", value: "1000" }],
positions: [
...portfolio.positions,
{
...portfolio.positions[0],
currentValue: { currency: "RUB", value: "50" },
figi: "FIGI-CURRENCY",
type: "CURRENCY",
},
...otherTypes.map(
type =>
({
...portfolio.positions[2],
currentValue:
type === "OPTION" ? { currency: "RUB", value: "50" } : null,
figi: `FIGI-${type}-NEXT`,
type,
}),
),
],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "1250" }],
};
const original = structuredClone(snapshot);
const sections = toAccountPagePositionSections(snapshot);
expect(sections.map(section => section.kind)).toEqual([
"stocks",
"bonds",
"other",
]);
expect(sections[2]?.groups.map(group => group.positions[0].figi)).toEqual([
"FIGI-FUND",
...otherTypes.map(type => `FIGI-${type}-NEXT`),
]);
expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("40 %");
expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("20 %");
expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("20 %");
expect(snapshot).toEqual(original);
});
it("prepares grouped position sections with derived display data", () => {
expect(
toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({
kind,
positions: groups.flatMap(group => group.positions),
})),
).toMatchObject([
{
kind: "stocks",
positions: [
{
displayName: "Акция",
logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png",
returnPercent: null,
returnPercentText: null,
returnText: "+10 ₽",
returnTone: "positive",
shareText: "66,7 %",
},
],
},
{
kind: "bonds",
positions: [
{
displayName: "Облигация",
logoSrc: null,
returnPercent: "-11.111111",
returnPercentText: "-11,11 %",
returnText: "-5 ₽",
returnTone: "negative",
shareText: "33,3 %",
},
],
},
{
kind: "other",
positions: [
{
displayName: "Фонд",
logoSrc: null,
returnPercent: null,
returnPercentText: null,
returnText: null,
returnTone: undefined,
shareText: null,
},
],
},
]);
});
it("uses ISIN only when FIGI is absent, never merging by name or ticker", () => {
expect(
sections([
{
...position,
figi: null,
},
{ ...second, figi: null },
])[0]?.groups,
).toHaveLength(1);
expect(
sections([position, { ...second, figi: "OTHER-FIGI" }])[0]?.groups,
).toHaveLength(2);
expect(
sections([
{
...position,
figi: null,
isin: null,
},
{
...second,
figi: null,
isin: null,
},
])[0]?.groups,
).toHaveLength(2);
});
it("preserves one-account cards and does not merge repeated rows on the same account", () => {
expect(
sections([position, { ...second, account: position.account }])[0]?.groups,
).toHaveLength(2);
expect(
sections([
{ ...position, account: undefined },
{ ...second, account: undefined },
])[0]?.groups,
).toHaveLength(2);
});
it("keeps currencies and purchase prices attached to their source positions", () => {
const source = [
position,
{
...second,
averagePurchasePrice: { currency: "USD", value: "0.99" },
currentValue: { currency: "USD", value: "1.01" },
},
];
const before = structuredClone(source);
const group = sections(source)[0]?.groups[0];
expect(
group?.positions.map(item => [
item.quantity,
item.currentValue,
item.averagePurchasePrice,
]),
).toEqual(
source.map(item => [
item.quantity,
item.currentValue,
item.averagePurchasePrice,
]),
);
expect(source).toEqual(before);
});
});
describe("account position returns", () => {
it.each([
{
absoluteReturn: { currency: "RUB", value: "25" },
averagePurchasePrice: { currency: "RUB", value: "280" },
expected: "7.142857",
name: "gain",
quantity: "1.25",
type: "STOCK",
},
{
absoluteReturn: { currency: "RUB", value: "-25" },
averagePurchasePrice: { currency: "RUB", value: "280" },
expected: "-7.142857",
name: "loss",
quantity: "1.25",
type: "STOCK",
},
{
absoluteReturn: { currency: "RUB", value: "-5" },
averagePurchasePrice: { currency: "RUB", value: "45" },
expected: "-11.111111",
name: "bond without accrued interest",
quantity: "1",
type: "BOND",
},
] as const)("calculates $name from the clean purchase cost", (testCase) => {
const result = sections([makePosition({
absoluteReturn: testCase.absoluteReturn,
averagePurchasePrice: testCase.averagePurchasePrice,
quantity: testCase.quantity,
type: testCase.type,
})]);
expect(result[0]?.groups[0]?.positions[0].returnPercent)
.toBe(testCase.expected);
});
it.each([
{ name: "zero quantity", overrides: { quantity: "0" } },
{ name: "negative quantity", overrides: { quantity: "-1" } },
{ name: "missing average price", overrides: { averagePurchasePrice: null } },
{
name: "currency mismatch",
overrides: { averagePurchasePrice: { currency: "USD", value: "280" } },
},
{ name: "zero cost", overrides: { averagePurchasePrice: { currency: "RUB", value: "0" } } },
])("returns null for $name", ({ overrides }) => {
const result = sections([makePosition({
absoluteReturn: { currency: "RUB", value: "25" },
averagePurchasePrice: { currency: "RUB", value: "280" },
quantity: "1.25",
...overrides,
})]);
expect(result[0]?.groups[0]?.positions[0].returnPercent).toBeNull();
});
it.each([
{ absoluteReturn: "1", expected: "0.000001" },
{ absoluteReturn: "-1", expected: "-0.000001" },
])("rounds an exact half away from zero for $absoluteReturn", (testCase) => {
const result = sections([makePosition({
absoluteReturn: { currency: "RUB", value: testCase.absoluteReturn },
averagePurchasePrice: { currency: "RUB", value: "200000000" },
quantity: "1",
})]);
expect(result[0]?.groups[0]?.positions[0].returnPercent)
.toBe(testCase.expected);
});
it.each([
{ expected: "+1.01", value: "1.005" },
{ expected: "-1.01", value: "-1.005" },
])("formats $value for display with a sign and two decimals", (testCase) => {
expect(formatPositionReturnPercent(testCase.value)).toBe(testCase.expected);
});
it("sums complete unrealized returns by currency", () => {
const result = toUnrealizedReturns([
makePosition({ absoluteReturn: { currency: "RUB", value: "0.1" } }),
makePosition({ absoluteReturn: { currency: "RUB", value: "0.2" } }),
makePosition({ absoluteReturn: { currency: "USD", value: "-2.5" } }),
]);
expect(result).toEqual([
{ currency: "RUB", value: "0.3" },
{ currency: "USD", value: "-2.5" },
]);
});
it("omits only currencies with incomplete returns", () => {
const result = toUnrealizedReturns([
makePosition({ absoluteReturn: { currency: "RUB", value: "10" } }),
makePosition({
absoluteReturn: null,
currentValue: { currency: "RUB", value: "100" },
}),
makePosition({ absoluteReturn: { currency: "USD", value: "2" } }),
]);
expect(result).toEqual([{ currency: "USD", value: "2" }]);
});
});