From f165abcd5ee35c76c75e8d486b77c0864d380646 Mon Sep 17 00:00:00 2001 From: alfred micheal Date: Sun, 30 Aug 2026 11:13:00 +0100 Subject: [PATCH 1/4] test: add coverage for parseTokenAmount accept/reject boundaries (#250) --- lib/__tests__/format.test.ts | 82 +++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/lib/__tests__/format.test.ts b/lib/__tests__/format.test.ts index f3f1f48..94ac039 100644 --- a/lib/__tests__/format.test.ts +++ b/lib/__tests__/format.test.ts @@ -1,4 +1,4 @@ -import { formatUsdCents, formatTokenAmount } from "@/lib/format"; +import { formatUsdCents, formatTokenAmount, parseTokenAmount, formatRawPlain, compactNumber } from "@/lib/format"; // --------------------------------------------------------------------------- // Issue #35 — formatUsdCents @@ -176,3 +176,83 @@ 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(); + }); +}); From 2d7664db684c20dd5a4d4749a7ad6324fc563ff4 Mon Sep 17 00:00:00 2001 From: alfred micheal Date: Sun, 30 Aug 2026 11:13:17 +0100 Subject: [PATCH 2/4] test: add round-trip coverage for formatRawPlain against parseTokenAmount (#251) --- lib/__tests__/format.test.ts | 95 ++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/lib/__tests__/format.test.ts b/lib/__tests__/format.test.ts index 94ac039..f08d4d4 100644 --- a/lib/__tests__/format.test.ts +++ b/lib/__tests__/format.test.ts @@ -256,3 +256,98 @@ describe("parseTokenAmount", () => { 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"); + }); +}); From f0d883361cafc0b7943ec34b53cb58a6c280c61e Mon Sep 17 00:00:00 2001 From: alfred micheal Date: Sun, 30 Aug 2026 11:13:35 +0100 Subject: [PATCH 3/4] test: add coverage for compactNumber K/M/B thresholds and sub-1M non-compaction (#252) --- lib/__tests__/format.test.ts | 97 ++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/lib/__tests__/format.test.ts b/lib/__tests__/format.test.ts index f08d4d4..b2006ac 100644 --- a/lib/__tests__/format.test.ts +++ b/lib/__tests__/format.test.ts @@ -351,3 +351,100 @@ describe("formatRawPlain", () => { 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"); + }); +}); From f5125c61d92f36f30b0d765be39684a5b770945e Mon Sep 17 00:00:00 2001 From: alfred micheal Date: Sun, 30 Aug 2026 11:14:07 +0100 Subject: [PATCH 4/4] test: add route-level coverage for /portfolio (connect prompt, holdings list) (#249) --- app/portfolio/page.test.tsx | 111 ++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 app/portfolio/page.test.tsx diff --git a/app/portfolio/page.test.tsx b/app/portfolio/page.test.tsx new file mode 100644 index 0000000..0fa2567 --- /dev/null +++ b/app/portfolio/page.test.tsx @@ -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: () =>
Portfolio View
, +})); + +jest.mock("@/hooks/useWallet", () => ({ + useWallet: jest.fn(), +})); + +jest.mock("@/hooks/usePortfolio", () => ({ + usePortfolio: jest.fn(), +})); + +const mockUseWallet = useWallet as jest.MockedFunction; +const mockUsePortfolio = usePortfolio as jest.MockedFunction; + +const BASE_WALLET: ReturnType = { + 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(); +} + +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(); + }); +});