Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions app/portfolio/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { render, screen } from "@testing-library/react";
import PortfolioPage from "./page";
import { usePortfolio, type PortfolioData } from "@/hooks/usePortfolio";
import { useWallet } from "@/hooks/useWallet";

jest.mock("@/components/portfolio/PortfolioView", () => ({
PortfolioView: () => <div>Portfolio View</div>,
}));

jest.mock("@/hooks/useWallet", () => ({
useWallet: jest.fn(),
}));

jest.mock("@/hooks/usePortfolio", () => ({
usePortfolio: jest.fn(),
}));

const mockUseWallet = useWallet as jest.MockedFunction<typeof useWallet>;
const mockUsePortfolio = usePortfolio as jest.MockedFunction<typeof usePortfolio>;

const BASE_WALLET: ReturnType<typeof useWallet> = {
address: null,
connect: jest.fn().mockResolvedValue(undefined),
disconnect: jest.fn(),
connecting: false,
installed: true,
network: "testnet",
walletNetwork: null,
networkUnknown: false,
error: null,
setNetwork: jest.fn(),
sign: jest.fn(),
writeCtx: jest.fn(),
};

const PORTFOLIO_WITH_HOLDING: PortfolioData = {
holdings: [
{
asset: {
id: 1n,
tokenContract: "CABC",
issuer: "GABC",
name: "Harbor View Apartments",
assetType: "real_estate",
valuation: 25000000n,
createdAt: 1,
active: true,
},
metadata: {
name: "Harbor View Apartments",
symbol: "HVA",
assetType: "real_estate",
totalSupply: 1000000n,
decimals: 2,
admin: "GABC",
complianceContract: "CCABC",
assetDescription: "Tokenized apartments",
valuation: 25000000n,
paused: false,
},
balance: 10000n,
claimableDistributions: [],
totalClaimable: 0n,
},
],
totalValueCents: 250000n,
totalClaimable: 0n,
};

function setup({ address = null, data = PORTFOLIO_WITH_HOLDING }: {
address?: string | null;
data?: PortfolioData;
} = {}) {
mockUseWallet.mockReturnValue({ ...BASE_WALLET, address });
mockUsePortfolio.mockReturnValue({
data,
loading: false,
error: null,
refetch: jest.fn(),
});
return render(<PortfolioPage />);
}

describe("/portfolio page route", () => {
beforeEach(() => jest.clearAllMocks());

it("renders Portfolio page title", () => {
setup();
expect(screen.getByRole("heading", { level: 1, name: /portfolio/i })).toBeInTheDocument();
});

it("renders portfolio description", () => {
setup();
expect(screen.getByText(/Your tokenized asset balances and claimable dividend distributions/i)).toBeInTheDocument();
});

it("renders PortfolioView component", () => {
setup();
expect(screen.getByText("Portfolio View")).toBeInTheDocument();
});

it("passes holdings to PortfolioView when connected", () => {
setup({ address: "GCONNECTED123456789", data: PORTFOLIO_WITH_HOLDING });
expect(screen.getByText("Portfolio View")).toBeInTheDocument();
});

it("shows empty portfolio state when no holdings", () => {
setup({ address: "GCONNECTED123456789", data: { holdings: [], totalValueCents: 0n, totalClaimable: 0n } });
expect(screen.getByText("Portfolio View")).toBeInTheDocument();
});
});
274 changes: 273 additions & 1 deletion lib/__tests__/format.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { formatUsdCents, formatTokenAmount } from "@/lib/format";
import { formatUsdCents, formatTokenAmount, parseTokenAmount, formatRawPlain, compactNumber } from "@/lib/format";

// ---------------------------------------------------------------------------
// Issue #35 — formatUsdCents
Expand Down Expand Up @@ -176,3 +176,275 @@ describe("formatTokenAmount", () => {
expect(formatTokenAmount(10_000_000n, 7)).toBe("1");
});
});

// ---------------------------------------------------------------------------
// Issue #250 — parseTokenAmount
// ---------------------------------------------------------------------------
describe("parseTokenAmount", () => {
// --- accepted inputs -------------------------------------------------------

it("accepts and parses single digit '5'", () => {
expect(parseTokenAmount("5", 2)).toBe(500n);
});

it("accepts and parses decimal '5.5' at 2 decimals", () => {
expect(parseTokenAmount("5.5", 2)).toBe(550n);
});

it("accepts and parses leading-decimal '.5' at 2 decimals", () => {
expect(parseTokenAmount(".5", 2)).toBe(50n);
});

// --- rejected inputs -------------------------------------------------------

it("rejects empty string", () => {
expect(() => parseTokenAmount("", 2)).toThrow();
});

it("rejects a lone dot '.'", () => {
expect(() => parseTokenAmount(".", 2)).toThrow();
});

it("rejects trailing dot '5.'", () => {
expect(() => parseTokenAmount("5.", 2)).toThrow();
});

it("rejects negative number '-5'", () => {
expect(() => parseTokenAmount("-5", 2)).toThrow();
});

it("rejects scientific notation '1e5'", () => {
expect(() => parseTokenAmount("1e5", 2)).toThrow();
});

// --- decimal place limits --------------------------------------------------

it("rejects input with more decimal places than allowed", () => {
// "1.123" has 3 decimal places, but max is 2
expect(() => parseTokenAmount("1.123", 2)).toThrow();
});

it("accepts input with exactly the allowed decimal places", () => {
// "1.12" has 2 decimal places, max is 2
expect(parseTokenAmount("1.12", 2)).toBe(112n);
});

// --- comma handling --------------------------------------------------------

it("strips commas from input before parsing", () => {
// "1,000.5" → removes commas → "1000.5" → parses correctly
expect(parseTokenAmount("1,000.5", 2)).toBe(100050n);
});

it("handles multiple commas correctly", () => {
expect(parseTokenAmount("1,000,000.25", 2)).toBe(100000025n);
});

// --- whitespace handling ---------------------------------------------------

it("trims leading and trailing whitespace", () => {
expect(parseTokenAmount(" 5.5 ", 2)).toBe(550n);
});

// --- zero-decimals case ---------------------------------------------------

it("parses at 0 decimals (integer-only token)", () => {
expect(parseTokenAmount("100", 0)).toBe(100n);
});

it("rejects fractional input when decimals=0", () => {
expect(() => parseTokenAmount("1.5", 0)).toThrow();
});
});

// ---------------------------------------------------------------------------
// Issue #251 — formatRawPlain & round-trip with parseTokenAmount
// ---------------------------------------------------------------------------
describe("formatRawPlain", () => {
// --- round-trip tests ------------------------------------------------------

it("round-trips a whole number through parseTokenAmount", () => {
const input = "5";
const parsed = parseTokenAmount(input, 2);
const formatted = formatRawPlain(parsed, 2);
expect(formatted).toBe("5");
});

it("round-trips a decimal number through parseTokenAmount", () => {
const input = "5.5";
const parsed = parseTokenAmount(input, 2);
const formatted = formatRawPlain(parsed, 2);
expect(formatted).toBe("5.5");
});

it("round-trips a leading-decimal number through parseTokenAmount", () => {
const input = ".5";
const parsed = parseTokenAmount(input, 2);
const formatted = formatRawPlain(parsed, 2);
expect(formatted).toBe("0.5");
});

it("round-trips a large number with many digits", () => {
const input = "1000000.12";
const parsed = parseTokenAmount(input, 2);
const formatted = formatRawPlain(parsed, 2);
expect(formatted).toBe("1000000.12");
});

it("round-trips a number at max decimal precision", () => {
const input = "1.123456";
const parsed = parseTokenAmount(input, 6);
const formatted = formatRawPlain(parsed, 6);
expect(formatted).toBe("1.123456");
});

// --- trailing zero handling ------------------------------------------------

it("trims trailing zeros from formatted output", () => {
// Input "1.50" with 2 decimals: parses to 150n, formats back without trailing zero
const parsed = parseTokenAmount("1.5", 2);
const formatted = formatRawPlain(parsed, 2);
expect(formatted).toBe("1.5");
});

it("preserves significant zeros but trims trailing zeros", () => {
// "1.05" parses to 105n (at 2 decimals), formats to "1.05" (no trailing zero)
const parsed = parseTokenAmount("1.05", 2);
const formatted = formatRawPlain(parsed, 2);
expect(formatted).toBe("1.05");
});

it("trims all fractional zeros when fraction is exactly zero", () => {
const parsed = parseTokenAmount("1", 2);
const formatted = formatRawPlain(parsed, 2);
expect(formatted).toBe("1");
});

it("handles value with many trailing zeros", () => {
// Raw 100n with 6 decimals = 0.0001, but formatRawPlain should trim to "0.0001"
const raw = 100n;
const formatted = formatRawPlain(raw, 6);
expect(formatted).toBe("0.0001");
});

// --- no thousands separators -----------------------------------------------

it("does not include thousands separators (unlike formatTokenAmount)", () => {
const raw = 1_000_000n;
const formatted = formatRawPlain(raw, 2);
expect(formatted).toBe("10000");
expect(formatted).not.toMatch(/,/);
});

// --- negative amounts ------------------------------------------------------

it("formats negative raw amounts with leading minus", () => {
const raw = -500n;
const formatted = formatRawPlain(raw, 2);
expect(formatted).toBe("-5");
});

it("round-trips negative amounts (if input were to be accepted)", () => {
// Note: parseTokenAmount rejects negative input, so this tests formatRawPlain's negative handling
const raw = -550n;
const formatted = formatRawPlain(raw, 2);
expect(formatted).toBe("-5.5");
});
});

// ---------------------------------------------------------------------------
// Issue #252 — compactNumber
// ---------------------------------------------------------------------------
describe("compactNumber", () => {
// --- sub-1000 (no compaction) ----------------------------------------------

it("does not compact values below 1,000", () => {
expect(compactNumber(999)).toBe("999");
});

it("does not compact value exactly 999", () => {
expect(compactNumber(999)).toBe("999");
});

it("returns plain string for 0", () => {
expect(compactNumber(0)).toBe("0");
});

it("returns plain string for 1", () => {
expect(compactNumber(1)).toBe("1");
});

// --- K threshold (1,000) ---------------------------------------------------

it("compacts exactly 1,000 as '1K'", () => {
expect(compactNumber(1_000)).toBe("1K");
});

it("compacts 1,200 as '1.2K'", () => {
expect(compactNumber(1_200)).toBe("1.2K");
});

it("compacts 12,345 as '12.3K'", () => {
expect(compactNumber(12_345)).toBe("12.3K");
});

it("compacts 999,999 as '1000K'", () => {
// 999999 / 1000 = 999.999, toFixed(1) = "1000.0", trimZero = "1000"
expect(compactNumber(999_999)).toBe("1000K");
});

// --- M threshold (1,000,000) -----------------------------------------------

it("compacts exactly 1,000,000 as '1M'", () => {
expect(compactNumber(1_000_000)).toBe("1M");
});

it("compacts 1,500,000 as '1.5M'", () => {
expect(compactNumber(1_500_000)).toBe("1.5M");
});

it("compacts 12,345,678 as '12.3M'", () => {
expect(compactNumber(12_345_678)).toBe("12.3M");
});

it("compacts 999,999,999 as '1000M'", () => {
// Just below 1B, should still use M suffix
expect(compactNumber(999_999_999)).toBe("1000M");
});

// --- B threshold (1,000,000,000) -------------------------------------------

it("compacts exactly 1,000,000,000 as '1B'", () => {
expect(compactNumber(1_000_000_000)).toBe("1B");
});

it("compacts 2,500,000,000 as '2.5B'", () => {
expect(compactNumber(2_500_000_000)).toBe("2.5B");
});

it("compacts 12,345,678,901 as '12.3B'", () => {
expect(compactNumber(12_345_678_901)).toBe("12.3B");
});

// --- negative numbers ------------------------------------------------------

it("compacts absolute value of negative numbers", () => {
expect(compactNumber(-1_000_000)).toBe("-1M");
});

it("does not compact negative values below -1000", () => {
expect(compactNumber(-999)).toBe("-999");
});

// --- decimal rounding (trimZero behavior) ---------------------------------

it("trims .0 suffix from rounded values", () => {
// 1000000 / 1000000 = 1.0 → trimmed to "1"
expect(compactNumber(1_000_000)).toBe("1M");
});

it("keeps single decimal when significant", () => {
// 1234567 / 1000000 = 1.234567, toFixed(1) = "1.2" (not trimmed)
expect(compactNumber(1_234_567)).toBe("1.2M");
});
});