diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ee6094..470ef00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,11 +66,19 @@ jobs: - name: Install dependencies run: npm ci - - name: Run tests - run: npm run test + - name: Run tests with coverage + run: npm run test:coverage + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: frontend-coverage + path: frontend/coverage/ + retention-days: 14 - name: Build run: npm run build - name: Verify build - run: echo "✅ Frontend build and tests successful" + run: echo "✅ Frontend build, tests, and coverage successful" diff --git a/backend/src/test/java/com/moshimo/backend/domain/service/BenchmarkServiceTest.java b/backend/src/test/java/com/moshimo/backend/domain/service/BenchmarkServiceTest.java new file mode 100644 index 0000000..516fb7b --- /dev/null +++ b/backend/src/test/java/com/moshimo/backend/domain/service/BenchmarkServiceTest.java @@ -0,0 +1,244 @@ +package com.moshimo.backend.domain.service; + +import com.moshimo.backend.application.dto.request.Timeframe; +import com.moshimo.backend.application.dto.response.SimulationResponse; +import com.moshimo.backend.domain.model.Asset; +import com.moshimo.backend.domain.model.AssetPrice; +import com.moshimo.backend.domain.model.AssetType; +import com.moshimo.backend.domain.repository.AssetPriceRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for BenchmarkService. + * + * Covers: + * - Real SPY data path (happy path) + * - Empty SPY data → CAGR fallback + * - SPY fetch exception → CAGR fallback + * - CAGR fallback value correctness + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("BenchmarkService Tests") +class BenchmarkServiceTest { + + @Mock + private AssetService assetService; + + @Mock + private AssetPriceRepository assetPriceRepository; + + @Mock + private TimelineAggregator timelineAggregator; + + @InjectMocks + private BenchmarkService benchmarkService; + + private Asset spyAsset; + + @BeforeEach + void setUp() { + spyAsset = Asset.builder() + .id(99L) + .symbol("SPY") + .name("SPDR S&P 500 ETF Trust") + .assetType(AssetType.ETF) + .build(); + } + + // ── Real SPY data path ────────────────────────────────────────────── + + @Test + @DisplayName("SPY data available → portfolio values scaled to SPY growth") + void calculateBenchmarkTimeline_withSpyData_returnsScaledTimeline() { + // Arrange + LocalDate start = LocalDate.of(2023, 1, 3); + LocalDate end = LocalDate.of(2023, 1, 5); + BigDecimal invested = new BigDecimal("10000.00"); + + AssetPrice day1 = price(start, "380.00"); + AssetPrice day2 = price(LocalDate.of(2023, 1, 4), "390.00"); + AssetPrice day3 = price(end, "400.00"); + + when(assetService.getAssetEntityBySymbol("SPY")).thenReturn(spyAsset); + when(assetPriceRepository.findByAssetIdAndDateBetween(eq(99L), eq(start), eq(end))) + .thenReturn(List.of(day1, day2, day3)); + + // TimelineAggregator: pass-through for simplicity + when(timelineAggregator.aggregateTimeline(anyList(), eq(Timeframe.ALL))) + .thenAnswer(inv -> inv.getArgument(0)); + + // Act + List result = + benchmarkService.calculateBenchmarkTimeline(start, end, invested, Timeframe.ALL); + + // Assert + assertEquals(3, result.size()); + + // Day 1: shares = 10000 / 380 = 26.31578947; value = 26.31578947 * 380 = 10000.00 + assertEquals(0, new BigDecimal("10000.00").compareTo(result.get(0).value())); + + // Day 3: shares * 400 = 26.31578947 * 400 ≈ 10526.32 + BigDecimal expectedEnd = invested.divide(new BigDecimal("380.00"), 8, RoundingMode.HALF_UP) + .multiply(new BigDecimal("400.00")) + .setScale(2, RoundingMode.HALF_UP); + assertEquals(0, expectedEnd.compareTo(result.get(2).value())); + } + + @Test + @DisplayName("SPY data available → timeline is aggregated through TimelineAggregator") + void calculateBenchmarkTimeline_withSpyData_delegatesToAggregator() { + // Arrange + LocalDate start = LocalDate.of(2023, 1, 3); + LocalDate end = LocalDate.of(2023, 6, 30); + BigDecimal invested = new BigDecimal("5000.00"); + + AssetPrice singlePrice = price(start, "400.00"); + + when(assetService.getAssetEntityBySymbol("SPY")).thenReturn(spyAsset); + when(assetPriceRepository.findByAssetIdAndDateBetween(eq(99L), eq(start), eq(end))) + .thenReturn(List.of(singlePrice)); + when(timelineAggregator.aggregateTimeline(anyList(), eq(Timeframe.ONE_MONTH))) + .thenReturn(List.of(new SimulationResponse.TimelinePoint(start, invested))); + + // Act + List result = + benchmarkService.calculateBenchmarkTimeline(start, end, invested, Timeframe.ONE_MONTH); + + // Assert + verify(timelineAggregator).aggregateTimeline(anyList(), eq(Timeframe.ONE_MONTH)); + assertEquals(1, result.size()); + } + + // ── Empty SPY data → CAGR fallback ────────────────────────────────── + + @Test + @DisplayName("SPY data empty → falls back to 10% CAGR calculation") + void calculateBenchmarkTimeline_emptySpyData_fallsBackToCAGR() { + // Arrange + LocalDate start = LocalDate.of(2023, 1, 1); + LocalDate end = LocalDate.of(2024, 1, 1); // exactly 1 year + BigDecimal invested = new BigDecimal("10000.00"); + + when(assetService.getAssetEntityBySymbol("SPY")).thenReturn(spyAsset); + when(assetPriceRepository.findByAssetIdAndDateBetween(eq(99L), eq(start), eq(end))) + .thenReturn(Collections.emptyList()); + when(timelineAggregator.aggregateTimeline(anyList(), eq(Timeframe.ALL))) + .thenAnswer(inv -> inv.getArgument(0)); + + // Act + List result = + benchmarkService.calculateBenchmarkTimeline(start, end, invested, Timeframe.ALL); + + // Assert — CAGR fallback generates daily points from start to end + assertFalse(result.isEmpty()); + // First point is the invested amount + assertEquals(0, invested.compareTo(result.get(0).value())); + // Last point should be ~10% higher (1 year at 10% CAGR) + SimulationResponse.TimelinePoint last = result.get(result.size() - 1); + // 10000 * 1.10^1 = 11000.00 + assertTrue(last.value().compareTo(new BigDecimal("10900.00")) > 0); + assertTrue(last.value().compareTo(new BigDecimal("11100.00")) < 0); + } + + // ── SPY fetch exception → CAGR fallback ───────────────────────────── + + @Test + @DisplayName("SPY fetch throws exception → fallback used, no exception propagates") + void calculateBenchmarkTimeline_spyThrowsException_fallsBackToCAGR() { + // Arrange + LocalDate start = LocalDate.of(2023, 6, 1); + LocalDate end = LocalDate.of(2023, 6, 30); + BigDecimal invested = new BigDecimal("5000.00"); + + when(assetService.getAssetEntityBySymbol("SPY")) + .thenThrow(new RuntimeException("SPY not found in database")); + when(timelineAggregator.aggregateTimeline(anyList(), eq(Timeframe.ALL))) + .thenAnswer(inv -> inv.getArgument(0)); + + // Act — should NOT throw + List result = + benchmarkService.calculateBenchmarkTimeline(start, end, invested, Timeframe.ALL); + + // Assert — got a CAGR fallback timeline instead of an exception + assertFalse(result.isEmpty()); + assertEquals(start, result.get(0).date()); + assertEquals(0, invested.compareTo(result.get(0).value())); + } + + @Test + @DisplayName("Repository exception mid-flow → CAGR fallback") + void calculateBenchmarkTimeline_repositoryThrows_fallsBackToCAGR() { + // Arrange + LocalDate start = LocalDate.of(2023, 1, 1); + LocalDate end = LocalDate.of(2023, 12, 31); + BigDecimal invested = new BigDecimal("8000.00"); + + when(assetService.getAssetEntityBySymbol("SPY")).thenReturn(spyAsset); + when(assetPriceRepository.findByAssetIdAndDateBetween(eq(99L), any(), any())) + .thenThrow(new RuntimeException("DB connection lost")); + when(timelineAggregator.aggregateTimeline(anyList(), eq(Timeframe.ALL))) + .thenAnswer(inv -> inv.getArgument(0)); + + // Act + List result = + benchmarkService.calculateBenchmarkTimeline(start, end, invested, Timeframe.ALL); + + // Assert + assertFalse(result.isEmpty()); + assertEquals(0, invested.compareTo(result.get(0).value())); + } + + // ── CAGR math correctness ─────────────────────────────────────────── + + @Test + @DisplayName("CAGR fallback - 2 year period grows ≈ 21%") + void calculateBenchmarkTimeline_cagrFallback_twoYearsGrows21Percent() { + // Arrange — 2 full years + LocalDate start = LocalDate.of(2022, 1, 1); + LocalDate end = LocalDate.of(2024, 1, 1); // 731 days + BigDecimal invested = new BigDecimal("10000.00"); + + when(assetService.getAssetEntityBySymbol("SPY")) + .thenThrow(new RuntimeException("no SPY")); + when(timelineAggregator.aggregateTimeline(anyList(), eq(Timeframe.ALL))) + .thenAnswer(inv -> inv.getArgument(0)); + + // Act + List result = + benchmarkService.calculateBenchmarkTimeline(start, end, invested, Timeframe.ALL); + + // Assert — 10000 * 1.10^2 = 12100 + SimulationResponse.TimelinePoint last = result.get(result.size() - 1); + assertTrue(last.value().compareTo(new BigDecimal("12000.00")) > 0, + "Expected > 12000, got " + last.value()); + assertTrue(last.value().compareTo(new BigDecimal("12200.00")) < 0, + "Expected < 12200, got " + last.value()); + } + + // ── Helper ────────────────────────────────────────────────────────── + + private AssetPrice price(LocalDate date, String close) { + return AssetPrice.builder() + .asset(spyAsset) + .date(date) + .close(new BigDecimal(close)) + .adjustedClose(new BigDecimal(close)) + .build(); + } +} diff --git a/backend/src/test/java/com/moshimo/backend/domain/service/TimelineAggregatorTest.java b/backend/src/test/java/com/moshimo/backend/domain/service/TimelineAggregatorTest.java new file mode 100644 index 0000000..3347f9e --- /dev/null +++ b/backend/src/test/java/com/moshimo/backend/domain/service/TimelineAggregatorTest.java @@ -0,0 +1,274 @@ +package com.moshimo.backend.domain.service; + +import com.moshimo.backend.application.dto.request.Timeframe; +import com.moshimo.backend.application.dto.response.SimulationResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for TimelineAggregator. + * + * Plain unit tests — no Spring context, no mocks needed. + * TimelineAggregator is a pure function class. + */ +@DisplayName("TimelineAggregator Tests") +class TimelineAggregatorTest { + + private TimelineAggregator aggregator; + + @BeforeEach + void setUp() { + aggregator = new TimelineAggregator(); + } + + // ── Edge cases ────────────────────────────────────────────────────── + + @Test + @DisplayName("Empty timeline → returns empty list for any timeframe") + void aggregateTimeline_emptyInput_returnsEmpty() { + for (Timeframe tf : Timeframe.values()) { + List result = + aggregator.aggregateTimeline(Collections.emptyList(), tf); + assertTrue(result.isEmpty(), "Expected empty for timeframe " + tf); + } + } + + @Test + @DisplayName("Single point → returns that point for any timeframe") + void aggregateTimeline_singlePoint_returnsSamePoint() { + SimulationResponse.TimelinePoint point = + point(LocalDate.of(2024, 3, 15), "1000.00"); + List input = List.of(point); + + for (Timeframe tf : Timeframe.values()) { + List result = + aggregator.aggregateTimeline(input, tf); + assertEquals(1, result.size(), "timeframe " + tf); + assertEquals(point, result.get(0)); + } + } + + // ── ONE_DAY ───────────────────────────────────────────────────────── + + @Nested + @DisplayName("ONE_DAY (daily)") + class OneDayTests { + + @Test + @DisplayName("Returns all points unchanged") + void aggregateTimeline_oneDay_returnsAllPoints() { + List daily = generateDailyPoints( + LocalDate.of(2024, 1, 1), 30); + + List result = + aggregator.aggregateTimeline(daily, Timeframe.ONE_DAY); + + assertEquals(daily.size(), result.size()); + assertEquals(daily, result); + } + } + + // ── ONE_WEEK ──────────────────────────────────────────────────────── + + @Nested + @DisplayName("ONE_WEEK (weekly)") + class OneWeekTests { + + @Test + @DisplayName("5 weeks of daily data → 5 weekly points") + void aggregateTimeline_oneWeek_samplesFirstDayOfEachWeek() { + // 35 calendar days → ~5 weeks + List daily = generateDailyPoints( + LocalDate.of(2024, 1, 1), 35); // Mon Jan 1 2024 + + List result = + aggregator.aggregateTimeline(daily, Timeframe.ONE_WEEK); + + // Each result point should be from a different ISO week (Monday-based) + Set weekStarts = result.stream() + .map(p -> p.date().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))) + .collect(Collectors.toSet()); + assertEquals(result.size(), weekStarts.size(), "Each point should be from a distinct week"); + + assertTrue(result.size() >= 5 && result.size() <= 6, + "Expected 5-6 weekly points, got " + result.size()); + } + + @Test + @DisplayName("Weekly output is strictly fewer than daily input") + void aggregateTimeline_oneWeek_reducesPointCount() { + List daily = generateDailyPoints( + LocalDate.of(2024, 1, 1), 60); + + List result = + aggregator.aggregateTimeline(daily, Timeframe.ONE_WEEK); + + assertTrue(result.size() < daily.size()); + } + } + + // ── ONE_MONTH ─────────────────────────────────────────────────────── + + @Nested + @DisplayName("ONE_MONTH (monthly)") + class OneMonthTests { + + @Test + @DisplayName("6 months of daily data → 6 monthly points") + void aggregateTimeline_oneMonth_samplesFirstDayOfEachMonth() { + // ~180 days across Jan–Jun + List daily = generateDailyPoints( + LocalDate.of(2024, 1, 1), 180); + + List result = + aggregator.aggregateTimeline(daily, Timeframe.ONE_MONTH); + + // Each result point should be from a different year-month + Set months = result.stream() + .map(p -> YearMonth.from(p.date())) + .collect(Collectors.toSet()); + assertEquals(result.size(), months.size(), "Each point should be from a distinct month"); + + assertEquals(6, result.size(), "Expected 6 monthly points for ~180 days from Jan"); + } + + @Test + @DisplayName("Monthly output ≤ 12 points for 1 year of data") + void aggregateTimeline_oneMonth_atMost12PointsPerYear() { + List daily = generateDailyPoints( + LocalDate.of(2024, 1, 1), 365); + + List result = + aggregator.aggregateTimeline(daily, Timeframe.ONE_MONTH); + + assertTrue(result.size() <= 13, "Expected ≤13 monthly points for 1 year, got " + result.size()); + assertTrue(result.size() >= 12, "Expected ≥12 monthly points for 1 year, got " + result.size()); + } + } + + // ── ONE_YEAR ──────────────────────────────────────────────────────── + + @Nested + @DisplayName("ONE_YEAR (yearly)") + class OneYearTests { + + @Test + @DisplayName("5 years of daily data → 5 yearly points") + void aggregateTimeline_oneYear_samplesFirstDayOfEachYear() { + // ~ 5 years of data + List daily = generateDailyPoints( + LocalDate.of(2020, 1, 1), 365 * 5); + + List result = + aggregator.aggregateTimeline(daily, Timeframe.ONE_YEAR); + + // Each point from a different year + Set years = result.stream() + .map(p -> p.date().getYear()) + .collect(Collectors.toSet()); + assertEquals(result.size(), years.size()); + + // 2020, 2021, 2022, 2023, 2024 → up to 5 (depending on exact day count) + assertTrue(result.size() >= 5 && result.size() <= 6, + "Expected 5-6 yearly points, got " + result.size()); + } + } + + // ── ALL (smart sampling) ──────────────────────────────────────────── + + @Nested + @DisplayName("ALL (smart sampling)") + class AllTests { + + @Test + @DisplayName("Data ≤ 500 points → returns all points unchanged") + void aggregateTimeline_all_underThreshold_returnsAll() { + List daily = generateDailyPoints( + LocalDate.of(2024, 1, 1), 300); + + List result = + aggregator.aggregateTimeline(daily, Timeframe.ALL); + + assertEquals(300, result.size()); + } + + @Test + @DisplayName("3000 points → output capped near 500") + void aggregateTimeline_all_3000Points_cappedAt500() { + List daily = generateDailyPoints( + LocalDate.of(2015, 1, 1), 3000); + + List result = + aggregator.aggregateTimeline(daily, Timeframe.ALL); + + // step = 3000/500 = 6, so ~500 sampled + possible last point = ~501 + assertTrue(result.size() <= 510, + "Expected ≤ 510 points, got " + result.size()); + assertTrue(result.size() >= 490, + "Expected ≥ 490 points, got " + result.size()); + } + + @Test + @DisplayName("Smart sampling always includes the last data point") + void aggregateTimeline_all_alwaysIncludesLastPoint() { + List daily = generateDailyPoints( + LocalDate.of(2015, 1, 1), 3000); + + List result = + aggregator.aggregateTimeline(daily, Timeframe.ALL); + + SimulationResponse.TimelinePoint expectedLast = daily.get(daily.size() - 1); + assertEquals(expectedLast, result.get(result.size() - 1), + "Last point of smart-sampled output should match last input point"); + } + + @Test + @DisplayName("Smart sampling preserves chronological order") + void aggregateTimeline_all_preservesOrder() { + List daily = generateDailyPoints( + LocalDate.of(2015, 1, 1), 2000); + + List result = + aggregator.aggregateTimeline(daily, Timeframe.ALL); + + for (int i = 1; i < result.size(); i++) { + assertTrue(result.get(i).date().isAfter(result.get(i - 1).date()) + || result.get(i).date().isEqual(result.get(i - 1).date()), + "Points should be in chronological order at index " + i); + } + } + } + + // ── Helpers ────────────────────────────────────────────────────────── + + /** + * Generate consecutive daily timeline points starting from the given date. + */ + private List generateDailyPoints(LocalDate start, int count) { + List points = new ArrayList<>(); + for (int i = 0; i < count; i++) { + BigDecimal value = BigDecimal.valueOf(1000 + i); + points.add(new SimulationResponse.TimelinePoint(start.plusDays(i), value)); + } + return points; + } + + private SimulationResponse.TimelinePoint point(LocalDate date, String value) { + return new SimulationResponse.TimelinePoint(date, new BigDecimal(value)); + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..785a308 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules dist dist-ssr +coverage *.local # Editor directories and files diff --git a/frontend/src/test/InvestmentBuilder.test.tsx b/frontend/src/test/InvestmentBuilder.test.tsx new file mode 100644 index 0000000..05cf7be --- /dev/null +++ b/frontend/src/test/InvestmentBuilder.test.tsx @@ -0,0 +1,111 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { InvestmentBuilder } from '../components/InvestmentBuilder'; +import type { Asset } from '../types/api.types'; + +// ── Test fixtures ─────────────────────────────────────────────────────── + +const mockAssets: Asset[] = [ + { + id: 1, + symbol: 'AAPL', + name: 'Apple Inc.', + assetType: 'STOCK', + sector: 'Technology', + industry: 'Consumer Electronics', + exchange: 'NASDAQ', + ipoDate: '1980-12-12', + isActive: true, + }, + { + id: 2, + symbol: 'MSFT', + name: 'Microsoft Corporation', + assetType: 'STOCK', + sector: 'Technology', + industry: 'Software', + exchange: 'NASDAQ', + ipoDate: '1986-03-13', + isActive: true, + }, +]; + +// ── Tests ─────────────────────────────────────────────────────────────── + +describe('InvestmentBuilder', () => { + it('renders the header and one empty investment form by default', () => { + const onSimulate = vi.fn(); + render( + , + ); + + expect(screen.getByText('Build Your Portfolio')).toBeInTheDocument(); + expect(screen.getByText(/Add up to 10 investments/i)).toBeInTheDocument(); + // Should show "0 of 1 investments valid" since the form is empty + expect(screen.getByText(/0 of 1 investments valid/i)).toBeInTheDocument(); + }); + + it('shows "Add Another Investment" button', () => { + const onSimulate = vi.fn(); + render( + , + ); + + const addBtn = screen.getByText('+ Add Another Investment'); + expect(addBtn).toBeInTheDocument(); + expect(addBtn).not.toBeDisabled(); + }); + + it('adds a second investment form when "Add Another Investment" is clicked', () => { + const onSimulate = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByText('+ Add Another Investment')); + + // Now should say "0 of 2 investments valid" + expect(screen.getByText(/0 of 2 investments valid/i)).toBeInTheDocument(); + }); + + it('disables simulate button when no valid investments', () => { + const onSimulate = vi.fn(); + render( + , + ); + + const simBtn = screen.getByRole('button', { name: /simulate/i }); + expect(simBtn).toBeDisabled(); + }); + + it('disables simulate button while simulating', () => { + const onSimulate = vi.fn(); + render( + , + ); + + const simBtn = screen.getByRole('button', { name: /simulating/i }); + expect(simBtn).toBeDisabled(); + }); + + it('shows "Simulating..." text when isSimulating is true', () => { + const onSimulate = vi.fn(); + render( + , + ); + + expect(screen.getByText(/Simulating\.\.\./i)).toBeInTheDocument(); + }); + + it('does not call onSimulate when button clicked with no valid investments', () => { + const onSimulate = vi.fn(); + render( + , + ); + + const simBtn = screen.getByRole('button', { name: /simulate/i }); + fireEvent.click(simBtn); + + expect(onSimulate).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/test/SimulationResults.test.tsx b/frontend/src/test/SimulationResults.test.tsx new file mode 100644 index 0000000..daf3d53 --- /dev/null +++ b/frontend/src/test/SimulationResults.test.tsx @@ -0,0 +1,202 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import { SimulationResults } from '../components/SimulationResults'; +import type { SimulationResponse } from '../types/api.types'; + +// Mock PortfolioChart — uses canvas-based lightweight-charts that won't work in jsdom +vi.mock('../components/PortfolioChart', () => ({ + PortfolioChart: () =>
Chart
, +})); + +// Mock PortfolioHeader — keeps test focused on SimulationResults logic +vi.mock('../components/PortfolioHeader', () => ({ + PortfolioHeader: () =>
Header
, +})); + +// ── Test fixtures ─────────────────────────────────────────────────────── + +const profitResults: SimulationResponse = { + totalInvested: 10000, + currentValue: 15000, + absoluteGain: 5000, + percentReturn: 50, + cagr: 14.87, + timeline: [ + { date: '2020-01-01', value: 10000 }, + { date: '2024-01-01', value: 15000 }, + ], + holdings: [ + { + symbol: 'AAPL', + name: 'Apple Inc.', + invested: 5000, + currentValue: 8000, + shares: 33.3333, + purchasePrice: 150, + currentPrice: 240, + absoluteGain: 3000, + percentReturn: 60, + }, + { + symbol: 'MSFT', + name: 'Microsoft Corporation', + invested: 5000, + currentValue: 7000, + shares: 16.6667, + purchasePrice: 300, + currentPrice: 420, + absoluteGain: 2000, + percentReturn: 40, + }, + ], + benchmarkTimeline: [ + { date: '2020-01-01', value: 10000 }, + { date: '2024-01-01', value: 14000 }, + ], +}; + +const lossResults: SimulationResponse = { + totalInvested: 10000, + currentValue: 8000, + absoluteGain: -2000, + percentReturn: -20, + cagr: -10.56, + timeline: [ + { date: '2023-01-01', value: 10000 }, + { date: '2024-01-01', value: 8000 }, + ], + holdings: [ + { + symbol: 'INTC', + name: 'Intel Corporation', + invested: 10000, + currentValue: 8000, + shares: 200, + purchasePrice: 50, + currentPrice: 40, + absoluteGain: -2000, + percentReturn: -20, + }, + ], +}; + +const investments = [ + { symbol: 'AAPL', amountUsd: 5000, purchaseDate: '2020-01-01' }, + { symbol: 'MSFT', amountUsd: 5000, purchaseDate: '2020-01-01' }, +]; + +// ── Tests ─────────────────────────────────────────────────────────────── + +describe('SimulationResults', () => { + describe('Metric cards', () => { + it('renders all 5 metric cards for profitable results', () => { + render(); + + const metrics = document.querySelector('.simulation-results__metrics')!; + const metricsEl = within(metrics as HTMLElement); + + expect(metricsEl.getByText('Total Invested')).toBeInTheDocument(); + expect(metricsEl.getByText('Current Value')).toBeInTheDocument(); + expect(metricsEl.getByText('Absolute Gain')).toBeInTheDocument(); + expect(metricsEl.getByText('Percent Return')).toBeInTheDocument(); + expect(metricsEl.getByText('CAGR')).toBeInTheDocument(); + }); + + it('displays formatted currency values', () => { + render(); + + const metrics = document.querySelector('.simulation-results__metrics')!; + const metricsEl = within(metrics as HTMLElement); + + // Total Invested: $10,000.00 + expect(metricsEl.getByText('$10,000.00')).toBeInTheDocument(); + // Current Value: $15,000.00 + expect(metricsEl.getByText('$15,000.00')).toBeInTheDocument(); + // Absolute Gain: $5,000.00 + expect(metricsEl.getByText('$5,000.00')).toBeInTheDocument(); + }); + + it('displays formatted percentage values', () => { + render(); + + // Percent Return: +50.00% + expect(screen.getByText('+50.00%')).toBeInTheDocument(); + // CAGR: +14.87% + expect(screen.getByText('+14.87%')).toBeInTheDocument(); + }); + }); + + describe('Holdings table', () => { + it('renders holdings table with correct headers', () => { + render(); + + const table = document.querySelector('.holdings-table')!; + const tableEl = within(table as HTMLElement); + + expect(tableEl.getByText('Holdings Breakdown')).toBeInTheDocument(); + expect(tableEl.getByText('Asset')).toBeInTheDocument(); + expect(tableEl.getByText('Invested')).toBeInTheDocument(); + expect(tableEl.getByText('Shares')).toBeInTheDocument(); + expect(tableEl.getByText('Purchase Price')).toBeInTheDocument(); + expect(tableEl.getByText('Current Price')).toBeInTheDocument(); + expect(tableEl.getByText('Current Value')).toBeInTheDocument(); + expect(tableEl.getByText('Gain/Loss')).toBeInTheDocument(); + expect(tableEl.getByText('Return %')).toBeInTheDocument(); + }); + + it('renders a row for each holding', () => { + render(); + + expect(screen.getByText('AAPL')).toBeInTheDocument(); + expect(screen.getByText('Apple Inc.')).toBeInTheDocument(); + expect(screen.getByText('MSFT')).toBeInTheDocument(); + expect(screen.getByText('Microsoft Corporation')).toBeInTheDocument(); + }); + + it('formats share counts with 4 decimal places', () => { + render(); + + expect(screen.getByText('33.3333')).toBeInTheDocument(); + expect(screen.getByText('16.6667')).toBeInTheDocument(); + }); + }); + + describe('Loss scenario', () => { + it('renders negative gain and return correctly', () => { + render(); + + // -$2,000.00 appears in both metric card and holdings row + const allLossValues = screen.getAllByText('-$2,000.00'); + expect(allLossValues.length).toBe(2); // metric card + table row + }); + + it('renders single holding with loss data', () => { + render(); + + expect(screen.getByText('INTC')).toBeInTheDocument(); + expect(screen.getByText('Intel Corporation')).toBeInTheDocument(); + }); + }); + + describe('Chart and header integration', () => { + it('renders the mocked PortfolioChart', () => { + render(); + + expect(screen.getByTestId('portfolio-chart')).toBeInTheDocument(); + }); + + it('renders the mocked PortfolioHeader when investments provided', () => { + render(); + + expect(screen.getByTestId('portfolio-header')).toBeInTheDocument(); + }); + }); + + describe('Data disclaimer', () => { + it('shows the data freshness disclaimer', () => { + render(); + + expect(screen.getByText(/Historical price data updated monthly/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index bcb2601..f65ae47 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1,3 +1,4 @@ +/// import { expect, afterEach } from 'vitest'; import { cleanup } from '@testing-library/react'; import * as matchers from '@testing-library/jest-dom/matchers';