view models

This commit is contained in:
2025-12-20 00:31:31 +01:00
parent 5c74837d73
commit 656ec62426
74 changed files with 4511 additions and 347 deletions

View File

@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import { ActivityItemViewModel } from './ActivityItemViewModel';
describe('ActivityItemViewModel', () => {
it('maps basic properties from input data', () => {
const data = {
id: 'activity-1',
type: 'race' as const,
message: 'Test activity',
time: '2025-01-01T12:00:00Z',
impressions: 1234,
};
const viewModel = new ActivityItemViewModel(data);
expect(viewModel.id).toBe('activity-1');
expect(viewModel.type).toBe('race');
expect(viewModel.message).toBe('Test activity');
expect(viewModel.time).toBe('2025-01-01T12:00:00Z');
expect(viewModel.impressions).toBe(1234);
});
it('returns the correct typeColor for each supported type', () => {
const race = new ActivityItemViewModel({ id: '1', type: 'race', message: '', time: '' });
const league = new ActivityItemViewModel({ id: '2', type: 'league', message: '', time: '' });
const team = new ActivityItemViewModel({ id: '3', type: 'team', message: '', time: '' });
const driver = new ActivityItemViewModel({ id: '4', type: 'driver', message: '', time: '' });
const platform = new ActivityItemViewModel({ id: '5', type: 'platform', message: '', time: '' });
expect(race.typeColor).toBe('bg-warning-amber');
expect(league.typeColor).toBe('bg-primary-blue');
expect(team.typeColor).toBe('bg-purple-400');
expect(driver.typeColor).toBe('bg-performance-green');
expect(platform.typeColor).toBe('bg-racing-red');
});
it('falls back to a default typeColor for unknown types', () => {
const unknown = new ActivityItemViewModel({
id: 'unknown',
type: 'unknown',
message: '',
time: '',
} as any);
expect(unknown.typeColor).toBe('bg-gray-500');
});
it('formats impressions when provided', () => {
const impressions = 1234;
const viewModel = new ActivityItemViewModel({
id: 'activity-2',
type: 'race',
message: '',
time: '',
impressions,
});
expect(viewModel.formattedImpressions).toBe(impressions.toLocaleString());
});
it('returns null for formattedImpressions when impressions is undefined or zero', () => {
const noImpressions = new ActivityItemViewModel({
id: 'activity-3',
type: 'race',
message: '',
time: '',
});
const zeroImpressions = new ActivityItemViewModel({
id: 'activity-4',
type: 'race',
message: '',
time: '',
impressions: 0,
});
expect(noImpressions.formattedImpressions).toBeNull();
expect(zeroImpressions.formattedImpressions).toBeNull();
});
});

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { AnalyticsDashboardViewModel } from './AnalyticsDashboardViewModel';
describe('AnalyticsDashboardViewModel', () => {
it('maps core fields from data', () => {
const vm = new AnalyticsDashboardViewModel({
totalUsers: 100,
activeUsers: 40,
totalRaces: 10,
totalLeagues: 5,
});
expect(vm.totalUsers).toBe(100);
expect(vm.activeUsers).toBe(40);
expect(vm.totalRaces).toBe(10);
expect(vm.totalLeagues).toBe(5);
});
it('computes engagement rate and formatted engagement rate', () => {
const vm = new AnalyticsDashboardViewModel({
totalUsers: 200,
activeUsers: 50,
totalRaces: 0,
totalLeagues: 0,
});
expect(vm.userEngagementRate).toBeCloseTo(25);
expect(vm.formattedEngagementRate).toBe('25.0%');
});
it('handles zero users safely', () => {
const vm = new AnalyticsDashboardViewModel({
totalUsers: 0,
activeUsers: 0,
totalRaces: 0,
totalLeagues: 0,
});
expect(vm.userEngagementRate).toBe(0);
expect(vm.formattedEngagementRate).toBe('0.0%');
expect(vm.activityLevel).toBe('Low');
});
it('derives activity level buckets from engagement rate', () => {
const low = new AnalyticsDashboardViewModel({ totalUsers: 100, activeUsers: 30, totalRaces: 0, totalLeagues: 0 });
const medium = new AnalyticsDashboardViewModel({ totalUsers: 100, activeUsers: 50, totalRaces: 0, totalLeagues: 0 });
const high = new AnalyticsDashboardViewModel({ totalUsers: 100, activeUsers: 90, totalRaces: 0, totalLeagues: 0 });
expect(low.activityLevel).toBe('Low');
expect(medium.activityLevel).toBe('Medium');
expect(high.activityLevel).toBe('High');
});
});

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { AnalyticsMetricsViewModel } from './AnalyticsMetricsViewModel';
describe('AnalyticsMetricsViewModel', () => {
it('maps raw metrics fields from data', () => {
const vm = new AnalyticsMetricsViewModel({
pageViews: 1234,
uniqueVisitors: 567,
averageSessionDuration: 180,
bounceRate: 42.5,
});
expect(vm.pageViews).toBe(1234);
expect(vm.uniqueVisitors).toBe(567);
expect(vm.averageSessionDuration).toBe(180);
expect(vm.bounceRate).toBe(42.5);
});
it('formats counts using locale formatting helpers', () => {
const vm = new AnalyticsMetricsViewModel({
pageViews: 1200,
uniqueVisitors: 3500,
averageSessionDuration: 75,
bounceRate: 10,
});
expect(vm.formattedPageViews).toBe((1200).toLocaleString());
expect(vm.formattedUniqueVisitors).toBe((3500).toLocaleString());
});
it('formats session duration as mm:ss', () => {
const vm = new AnalyticsMetricsViewModel({
pageViews: 0,
uniqueVisitors: 0,
averageSessionDuration: 125,
bounceRate: 0,
});
expect(vm.formattedSessionDuration).toBe('2:05');
});
it('formats bounce rate as percentage with one decimal', () => {
const vm = new AnalyticsMetricsViewModel({
pageViews: 0,
uniqueVisitors: 0,
averageSessionDuration: 0,
bounceRate: 37.345,
});
expect(vm.formattedBounceRate).toBe('37.3%');
});
});

View File

@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { AvailableLeaguesViewModel, AvailableLeagueViewModel } from './AvailableLeaguesViewModel';
describe('AvailableLeaguesViewModel', () => {
const baseLeague = {
id: 'league-1',
name: 'Pro Series',
game: 'iRacing',
drivers: 24,
avgViewsPerRace: 12_500,
mainSponsorSlot: { available: true, price: 5_000 },
secondarySlots: { available: 2, total: 3, price: 1_500 },
rating: 4.7,
tier: 'premium' as const,
nextRace: 'Next Sunday',
seasonStatus: 'active' as const,
description: 'Competitive league for serious drivers',
};
it('maps league array into view models', () => {
const vm = new AvailableLeaguesViewModel([baseLeague]);
expect(vm.leagues).toHaveLength(1);
expect(vm.leagues[0]).toBeInstanceOf(AvailableLeagueViewModel);
expect(vm.leagues[0].id).toBe(baseLeague.id);
expect(vm.leagues[0].name).toBe(baseLeague.name);
expect(vm.leagues[0].avgViewsPerRace).toBe(baseLeague.avgViewsPerRace);
});
it('exposes formatted average views and CPM for main sponsor slot', () => {
const leagueVm = new AvailableLeagueViewModel(baseLeague);
expect(leagueVm.formattedAvgViews).toBe(`${(baseLeague.avgViewsPerRace / 1000).toFixed(1)}k`);
const expectedCpm = Math.round((baseLeague.mainSponsorSlot.price / baseLeague.avgViewsPerRace) * 1000);
expect(leagueVm.cpm).toBe(expectedCpm);
expect(leagueVm.formattedCpm).toBe(`$${expectedCpm}`);
});
it('detects available sponsor slots from main or secondary slots', () => {
const withBothAvailable = new AvailableLeagueViewModel(baseLeague);
expect(withBothAvailable.hasAvailableSlots).toBe(true);
const onlySecondary = new AvailableLeagueViewModel({
...baseLeague,
mainSponsorSlot: { available: false, price: 5_000 },
secondarySlots: { available: 1, total: 3, price: 1_500 },
});
expect(onlySecondary.hasAvailableSlots).toBe(true);
const noneAvailable = new AvailableLeagueViewModel({
...baseLeague,
mainSponsorSlot: { available: false, price: 5_000 },
secondarySlots: { available: 0, total: 3, price: 1_500 },
});
expect(noneAvailable.hasAvailableSlots).toBe(false);
});
it('returns tier configuration for badge styling', () => {
const premium = new AvailableLeagueViewModel({ ...baseLeague, tier: 'premium' });
const standard = new AvailableLeagueViewModel({ ...baseLeague, tier: 'standard' });
const starter = new AvailableLeagueViewModel({ ...baseLeague, tier: 'starter' });
expect(premium.tierConfig.icon).toBe('⭐');
expect(standard.tierConfig.icon).toBe('🏆');
expect(starter.tierConfig.icon).toBe('🚀');
});
it('returns status configuration for season state', () => {
const active = new AvailableLeagueViewModel({ ...baseLeague, seasonStatus: 'active' });
const upcoming = new AvailableLeagueViewModel({ ...baseLeague, seasonStatus: 'upcoming' });
const completed = new AvailableLeagueViewModel({ ...baseLeague, seasonStatus: 'completed' });
expect(active.statusConfig.label).toBe('Active Season');
expect(upcoming.statusConfig.label).toBe('Starting Soon');
expect(completed.statusConfig.label).toBe('Season Ended');
});
});

View File

@@ -0,0 +1,186 @@
import { describe, it, expect } from 'vitest';
import { BillingViewModel, PaymentMethodViewModel, InvoiceViewModel, BillingStatsViewModel } from './BillingViewModel';
describe('BillingViewModel', () => {
it('maps arrays of payment methods, invoices and stats into view models', () => {
const data = {
paymentMethods: [
{ id: 'pm-1', type: 'card', last4: '4242', brand: 'Visa', isDefault: true, expiryMonth: 12, expiryYear: 2030 },
],
invoices: [
{
id: 'inv-1',
invoiceNumber: 'INV-1',
date: '2024-01-01',
dueDate: '2024-02-01',
amount: 100,
vatAmount: 19,
totalAmount: 119,
status: 'pending',
description: 'Sponsorship',
sponsorshipType: 'league',
pdfUrl: 'https://example.com/invoice.pdf',
},
],
stats: {
totalSpent: 1000,
pendingAmount: 200,
nextPaymentDate: '2024-03-01',
nextPaymentAmount: 50,
activeSponsorships: 3,
averageMonthlySpend: 250,
},
} as any;
const vm = new BillingViewModel(data);
expect(vm.paymentMethods).toHaveLength(1);
expect(vm.paymentMethods[0]).toBeInstanceOf(PaymentMethodViewModel);
expect(vm.invoices).toHaveLength(1);
expect(vm.invoices[0]).toBeInstanceOf(InvoiceViewModel);
expect(vm.stats).toBeInstanceOf(BillingStatsViewModel);
});
});
describe('PaymentMethodViewModel', () => {
it('builds displayLabel based on type and bankName/brand', () => {
const card = new PaymentMethodViewModel({
id: 'pm-1',
type: 'card',
last4: '4242',
brand: 'Visa',
isDefault: true,
});
const sepa = new PaymentMethodViewModel({
id: 'pm-2',
type: 'sepa',
last4: '1337',
bankName: 'Test Bank',
isDefault: false,
});
expect(card.displayLabel).toBe('Visa •••• 4242');
expect(sepa.displayLabel).toBe('Test Bank •••• 1337');
});
it('returns expiryDisplay when month and year are provided', () => {
const withExpiry = new PaymentMethodViewModel({
id: 'pm-1',
type: 'card',
last4: '4242',
brand: 'Visa',
isDefault: true,
expiryMonth: 3,
expiryYear: 2030,
});
const withoutExpiry = new PaymentMethodViewModel({
id: 'pm-2',
type: 'card',
last4: '9999',
brand: 'Mastercard',
isDefault: false,
});
expect(withExpiry.expiryDisplay).toBe('03/2030');
expect(withoutExpiry.expiryDisplay).toBeNull();
});
});
describe('InvoiceViewModel', () => {
it('formats monetary amounts and dates', () => {
const dto = {
id: 'inv-1',
invoiceNumber: 'INV-1',
date: '2024-01-15',
dueDate: '2024-02-01',
amount: 100,
vatAmount: 19,
totalAmount: 119,
status: 'paid',
description: 'Sponsorship',
sponsorshipType: 'league',
pdfUrl: 'https://example.com/invoice.pdf',
} as any;
const vm = new InvoiceViewModel(dto);
expect(vm.formattedTotalAmount).toBe(`${(119).toLocaleString('de-DE', { minimumFractionDigits: 2 })}`);
expect(vm.formattedVatAmount).toBe(`${(19).toLocaleString('de-DE', { minimumFractionDigits: 2 })}`);
expect(typeof vm.formattedDate).toBe('string');
});
it('detects overdue when status is overdue or pending past due date', () => {
const now = new Date();
const pastDate = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString();
const overdue = new InvoiceViewModel({
id: 'inv-1',
invoiceNumber: 'INV-1',
date: pastDate,
dueDate: pastDate,
amount: 0,
vatAmount: 0,
totalAmount: 0,
status: 'overdue',
description: '',
sponsorshipType: 'league',
pdfUrl: '',
} as any);
const pendingPastDue = new InvoiceViewModel({
id: 'inv-2',
invoiceNumber: 'INV-2',
date: pastDate,
dueDate: pastDate,
amount: 0,
vatAmount: 0,
totalAmount: 0,
status: 'pending',
description: '',
sponsorshipType: 'league',
pdfUrl: '',
} as any);
const pendingFuture = new InvoiceViewModel({
id: 'inv-3',
invoiceNumber: 'INV-3',
date: pastDate,
dueDate: futureDate,
amount: 0,
vatAmount: 0,
totalAmount: 0,
status: 'pending',
description: '',
sponsorshipType: 'league',
pdfUrl: '',
} as any);
expect(overdue.isOverdue).toBe(true);
expect(pendingPastDue.isOverdue).toBe(true);
expect(pendingFuture.isOverdue).toBe(false);
});
});
describe('BillingStatsViewModel', () => {
it('formats monetary fields and next payment date', () => {
const dto = {
totalSpent: 1234,
pendingAmount: 56.78,
nextPaymentDate: '2024-03-01',
nextPaymentAmount: 42,
activeSponsorships: 2,
averageMonthlySpend: 321,
} as any;
const vm = new BillingStatsViewModel(dto);
expect(vm.formattedTotalSpent).toBe(`${(1234).toLocaleString('de-DE')}`);
expect(vm.formattedPendingAmount).toBe(`${(56.78).toLocaleString('de-DE', { minimumFractionDigits: 2 })}`);
expect(vm.formattedNextPaymentAmount).toBe(`${(42).toLocaleString('de-DE', { minimumFractionDigits: 2 })}`);
expect(vm.formattedAverageMonthlySpend).toBe(`${(321).toLocaleString('de-DE')}`);
expect(typeof vm.formattedNextPaymentDate).toBe('string');
});
});

View File

@@ -3,22 +3,19 @@ import { CompleteOnboardingViewModel } from './CompleteOnboardingViewModel';
import type { CompleteOnboardingOutputDTO } from '../types/generated/CompleteOnboardingOutputDTO';
describe('CompleteOnboardingViewModel', () => {
it('should create instance with success and driverId', () => {
const dto: CompleteOnboardingOutputDTO & { driverId: string } = {
it('should create instance with success flag', () => {
const dto: CompleteOnboardingOutputDTO = {
success: true,
driverId: 'driver-123',
};
const viewModel = new CompleteOnboardingViewModel(dto);
expect(viewModel.success).toBe(true);
expect(viewModel.driverId).toBe('driver-123');
});
it('should return true for isSuccessful when success is true', () => {
const dto: CompleteOnboardingOutputDTO & { driverId: string } = {
it('should expose isSuccessful as true when success is true', () => {
const dto: CompleteOnboardingOutputDTO = {
success: true,
driverId: 'driver-123',
};
const viewModel = new CompleteOnboardingViewModel(dto);
@@ -26,31 +23,13 @@ describe('CompleteOnboardingViewModel', () => {
expect(viewModel.isSuccessful).toBe(true);
});
it('should return false for isSuccessful when success is false', () => {
const dto: CompleteOnboardingOutputDTO & { driverId: string } = {
it('should expose isSuccessful as false when success is false', () => {
const dto: CompleteOnboardingOutputDTO = {
success: false,
driverId: 'driver-123',
};
const viewModel = new CompleteOnboardingViewModel(dto);
expect(viewModel.isSuccessful).toBe(false);
});
it('should preserve driverId regardless of success status', () => {
const successDto: CompleteOnboardingOutputDTO & { driverId: string } = {
success: true,
driverId: 'driver-success',
};
const failDto: CompleteOnboardingOutputDTO & { driverId: string } = {
success: false,
driverId: 'driver-fail',
};
const successViewModel = new CompleteOnboardingViewModel(successDto);
const failViewModel = new CompleteOnboardingViewModel(failDto);
expect(successViewModel.driverId).toBe('driver-success');
expect(failViewModel.driverId).toBe('driver-fail');
});
});

View File

@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { CreateLeagueViewModel } from './CreateLeagueViewModel';
import type { CreateLeagueOutputDTO } from '../types/generated/CreateLeagueOutputDTO';
const createDto = (overrides: Partial<CreateLeagueOutputDTO> = {}): CreateLeagueOutputDTO => ({
leagueId: 'league-1',
success: true,
...overrides,
} as CreateLeagueOutputDTO);
describe('CreateLeagueViewModel', () => {
it('maps leagueId and success from DTO', () => {
const dto = createDto({ leagueId: 'league-123', success: true });
const vm = new CreateLeagueViewModel(dto);
expect(vm.leagueId).toBe('league-123');
expect(vm.success).toBe(true);
});
it('returns success successMessage when creation succeeded', () => {
const dto = createDto({ success: true });
const vm = new CreateLeagueViewModel(dto);
expect(vm.successMessage).toBe('League created successfully!');
});
it('returns failure successMessage when creation failed', () => {
const dto = createDto({ success: false });
const vm = new CreateLeagueViewModel(dto);
expect(vm.successMessage).toBe('Failed to create league.');
});
});

View File

@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { CreateTeamViewModel } from './CreateTeamViewModel';
describe('CreateTeamViewModel', () => {
it('maps id and success from DTO', () => {
const dto = { id: 'team-123', success: true };
const vm = new CreateTeamViewModel(dto);
expect(vm.id).toBe('team-123');
expect(vm.success).toBe(true);
});
it('returns success successMessage when creation succeeded', () => {
const dto = { id: 'team-1', success: true };
const vm = new CreateTeamViewModel(dto);
expect(vm.successMessage).toBe('Team created successfully!');
});
it('returns failure successMessage when creation failed', () => {
const dto = { id: 'team-1', success: false };
const vm = new CreateTeamViewModel(dto);
expect(vm.successMessage).toBe('Failed to create team.');
});
});

View File

@@ -0,0 +1,146 @@
import { describe, it, expect } from 'vitest';
import {
DashboardOverviewViewModel,
DriverViewModel,
RaceViewModel,
LeagueStandingViewModel,
DashboardFeedItemSummaryViewModel,
FriendViewModel,
} from './DashboardOverviewViewModel';
import type { DashboardOverviewDto } from '../api/dashboard/DashboardApiClient';
const createDashboardOverviewDto = (): DashboardOverviewDto => ({
currentDriver: {
id: 'driver-1',
name: 'Test Driver',
avatarUrl: 'https://example.com/avatar.jpg',
country: 'DE',
totalRaces: 10,
wins: 3,
podiums: 5,
rating: 2500,
globalRank: 42,
consistency: 88,
},
nextRace: {
id: 'race-1',
track: 'Spa-Francorchamps',
car: 'GT3',
scheduledAt: '2025-01-01T12:00:00Z',
isMyLeague: true,
leagueName: 'Pro League',
},
upcomingRaces: [
{
id: 'race-2',
track: 'Nürburgring',
car: 'GT4',
scheduledAt: '2025-01-02T12:00:00Z',
isMyLeague: false,
leagueName: undefined,
},
],
leagueStandings: [
{
leagueId: 'league-1',
leagueName: 'Pro League',
position: 1,
points: 120,
totalDrivers: 50,
},
],
feedItems: [
{
id: 'feed-1',
type: 'news',
headline: 'Big race announced',
body: 'Details about the big race.',
timestamp: '2025-01-01T10:00:00Z',
ctaHref: '/races/race-1',
ctaLabel: 'View race',
},
],
friends: [
{
id: 'friend-1',
name: 'Racing Buddy',
avatarUrl: 'https://example.com/friend.jpg',
country: 'US',
},
],
activeLeaguesCount: 3,
});
describe('DashboardOverviewViewModel', () => {
it('wraps the current driver DTO in a DriverViewModel', () => {
const dto = createDashboardOverviewDto();
const viewModel = new DashboardOverviewViewModel(dto);
const currentDriver = viewModel.currentDriver;
expect(currentDriver).toBeInstanceOf(DriverViewModel);
expect(currentDriver.id).toBe('driver-1');
expect(currentDriver.name).toBe('Test Driver');
expect(currentDriver.avatarUrl).toBe('https://example.com/avatar.jpg');
expect(currentDriver.country).toBe('DE');
expect(currentDriver.totalRaces).toBe(10);
expect(currentDriver.wins).toBe(3);
expect(currentDriver.podiums).toBe(5);
expect(currentDriver.rating).toBe(2500);
expect(currentDriver.globalRank).toBe(42);
expect(currentDriver.consistency).toBe(88);
});
it('wraps nextRace DTO into a RaceViewModel and returns null when absent', () => {
const dtoWithRace = createDashboardOverviewDto();
const viewModelWithRace = new DashboardOverviewViewModel(dtoWithRace);
const nextRace = viewModelWithRace.nextRace;
expect(nextRace).toBeInstanceOf(RaceViewModel);
expect(nextRace?.id).toBe('race-1');
expect(nextRace?.track).toBe('Spa-Francorchamps');
expect(nextRace?.car).toBe('GT3');
expect(nextRace?.isMyLeague).toBe(true);
expect(nextRace?.leagueName).toBe('Pro League');
expect(nextRace?.scheduledAt).toBeInstanceOf(Date);
const dtoWithoutRace: DashboardOverviewDto = {
...dtoWithRace,
nextRace: null,
};
const viewModelWithoutRace = new DashboardOverviewViewModel(dtoWithoutRace);
expect(viewModelWithoutRace.nextRace).toBeNull();
});
it('maps upcoming races, league standings, feed items and friends into their respective view models', () => {
const dto = createDashboardOverviewDto();
const viewModel = new DashboardOverviewViewModel(dto);
expect(viewModel.upcomingRaces).toHaveLength(1);
expect(viewModel.upcomingRaces[0]).toBeInstanceOf(RaceViewModel);
expect(viewModel.upcomingRaces[0].id).toBe('race-2');
expect(viewModel.leagueStandings).toHaveLength(1);
expect(viewModel.leagueStandings[0]).toBeInstanceOf(LeagueStandingViewModel);
expect(viewModel.leagueStandings[0].leagueId).toBe('league-1');
expect(viewModel.feedItems).toHaveLength(1);
expect(viewModel.feedItems[0]).toBeInstanceOf(DashboardFeedItemSummaryViewModel);
expect(viewModel.feedItems[0].id).toBe('feed-1');
expect(viewModel.feedItems[0].timestamp).toBeInstanceOf(Date);
expect(viewModel.friends).toHaveLength(1);
expect(viewModel.friends[0]).toBeInstanceOf(FriendViewModel);
expect(viewModel.friends[0].id).toBe('friend-1');
});
it('exposes the activeLeaguesCount from the DTO', () => {
const dto = createDashboardOverviewDto();
const viewModel = new DashboardOverviewViewModel(dto);
expect(viewModel.activeLeaguesCount).toBe(3);
});
});

View File

@@ -3,7 +3,7 @@ import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel
import { DriverLeaderboardItemDTO } from '../types/generated/DriverLeaderboardItemDTO';
describe('DriverLeaderboardItemViewModel', () => {
const mockDTO: DriverLeaderboardItemDTO = {
const baseDto: DriverLeaderboardItemDTO & { avatarUrl: string } = {
id: '1',
name: 'Test Driver',
rating: 1500,
@@ -13,63 +13,65 @@ describe('DriverLeaderboardItemViewModel', () => {
wins: 10,
podiums: 25,
isActive: true,
rank: 5
rank: 5,
avatarUrl: 'https://example.com/avatar.jpg',
};
it('should create instance from DTO', () => {
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1);
it('should create instance from DTO with avatar', () => {
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1);
expect(viewModel.id).toBe('1');
expect(viewModel.name).toBe('Test Driver');
expect(viewModel.position).toBe(1);
expect(viewModel.avatarUrl).toBe('https://example.com/avatar.jpg');
});
it('should calculate win rate correctly', () => {
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1);
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1);
expect(viewModel.winRate).toBe(20); // 10/50 * 100
});
it('should format win rate as percentage', () => {
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1);
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1);
expect(viewModel.winRateFormatted).toBe('20.0%');
});
it('should return correct skill level color', () => {
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1);
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1);
expect(viewModel.skillLevelColor).toBe('orange'); // advanced = orange
});
it('should return correct skill level icon', () => {
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1);
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1);
expect(viewModel.skillLevelIcon).toBe('🥇'); // advanced = 🥇
});
it('should detect rating trend up', () => {
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1, 1400);
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1, 1400);
expect(viewModel.ratingTrend).toBe('up');
});
it('should detect rating trend down', () => {
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1, 1600);
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1, 1600);
expect(viewModel.ratingTrend).toBe('down');
});
it('should show rating change indicator', () => {
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1, 1400);
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1, 1400);
expect(viewModel.ratingChangeIndicator).toBe('+100');
});
it('should handle zero races for win rate', () => {
const dto = { ...mockDTO, racesCompleted: 0, wins: 0 };
const dto = { ...baseDto, racesCompleted: 0, wins: 0 };
const viewModel = new DriverLeaderboardItemViewModel(dto, 1);
expect(viewModel.winRate).toBe(0);
});
});

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { DriverLeaderboardViewModel } from './DriverLeaderboardViewModel';
import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel';
import type { DriverLeaderboardItemDTO } from '../types/generated/DriverLeaderboardItemDTO';
const createDriver = (overrides: Partial<DriverLeaderboardItemDTO & { avatarUrl: string }> = {}): DriverLeaderboardItemDTO & { avatarUrl: string } => ({
id: 'driver-1',
name: 'Driver One',
rating: 1500,
skillLevel: 'advanced',
nationality: 'DE',
racesCompleted: 10,
wins: 3,
podiums: 5,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar-1.jpg',
...overrides,
});
describe('DriverLeaderboardViewModel', () => {
it('wraps DTO drivers into DriverLeaderboardItemViewModel instances', () => {
const drivers = [createDriver({ id: 'driver-1' }), createDriver({ id: 'driver-2', name: 'Driver Two' })];
const viewModel = new DriverLeaderboardViewModel({ drivers });
expect(viewModel.drivers).toHaveLength(2);
expect(viewModel.drivers[0]).toBeInstanceOf(DriverLeaderboardItemViewModel);
expect(viewModel.drivers[0].position).toBe(1);
expect(viewModel.drivers[1].position).toBe(2);
});
it('computes aggregate totals and active count', () => {
const drivers = [
createDriver({ id: 'driver-1', racesCompleted: 10, wins: 3, isActive: true }),
createDriver({ id: 'driver-2', racesCompleted: 5, wins: 1, isActive: false }),
];
const viewModel = new DriverLeaderboardViewModel({ drivers });
expect(viewModel.totalRaces).toBe(15);
expect(viewModel.totalWins).toBe(4);
expect(viewModel.activeCount).toBe(1);
});
it('passes previous rating to items when provided', () => {
const currentDrivers = [
createDriver({ id: 'driver-1', rating: 1550 }),
createDriver({ id: 'driver-2', rating: 1400 }),
];
const previousDrivers: (DriverLeaderboardItemDTO & { avatarUrl: string })[] = [
{ ...createDriver({ id: 'driver-1', rating: 1500 }) },
{ ...createDriver({ id: 'driver-2', rating: 1450 }) },
];
const viewModel = new DriverLeaderboardViewModel({ drivers: currentDrivers }, previousDrivers);
expect(viewModel.drivers[0].ratingTrend).toBe('up');
expect(viewModel.drivers[1].ratingTrend).toBe('down');
});
});

View File

@@ -4,7 +4,7 @@ import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel
export class DriverLeaderboardViewModel {
drivers: DriverLeaderboardItemViewModel[];
constructor(dto: { drivers: DriverLeaderboardItemDTO[] }, previousDrivers?: DriverLeaderboardItemDTO[]) {
constructor(dto: { drivers: (DriverLeaderboardItemDTO & { avatarUrl: string })[] }, previousDrivers?: (DriverLeaderboardItemDTO & { avatarUrl: string })[]) {
this.drivers = dto.drivers.map((driver, index) => {
const previous = previousDrivers?.find(p => p.id === driver.id);
return new DriverLeaderboardItemViewModel(driver, index + 1, previous?.rating);
@@ -13,7 +13,7 @@ export class DriverLeaderboardViewModel {
/** UI-specific: Total races across all drivers */
get totalRaces(): number {
return this.drivers.reduce((sum, driver) => sum + driver.races, 0);
return this.drivers.reduce((sum, driver) => sum + driver.racesCompleted, 0);
}
/** UI-specific: Total wins across all drivers */

View File

@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import { DriverProfileViewModel } from './DriverProfileViewModel';
const createDriverProfileDto = (): DriverProfileViewModel => ({
currentDriver: {
id: 'driver-1',
name: 'Test Driver',
country: 'DE',
avatarUrl: 'https://example.com/avatar.jpg',
iracingId: 'ir-123',
joinedAt: '2024-01-01T00:00:00Z',
rating: 2500,
globalRank: 42,
consistency: 88,
bio: 'Test bio',
totalDrivers: 1000,
},
stats: {
totalRaces: 10,
wins: 3,
podiums: 5,
dnfs: 1,
avgFinish: 4.2,
bestFinish: 1,
worstFinish: 15,
finishRate: 90,
winRate: 30,
podiumRate: 50,
percentile: 95,
rating: 2500,
consistency: 88,
overallRank: 10,
},
finishDistribution: {
totalRaces: 10,
wins: 3,
podiums: 5,
topTen: 8,
dnfs: 1,
other: 1,
},
teamMemberships: [
{
teamId: 'team-1',
teamName: 'Test Team',
teamTag: 'TT',
role: 'driver',
joinedAt: '2023-01-01T00:00:00Z',
isCurrent: true,
},
],
socialSummary: {
friendsCount: 5,
friends: [
{
id: 'friend-1',
name: 'Friend 1',
country: 'US',
avatarUrl: 'https://example.com/friend.jpg',
},
],
},
extendedProfile: {
socialHandles: [
{
platform: 'twitter',
handle: '@test',
url: 'https://twitter.com/test',
},
],
achievements: [
{
id: 'ach-1',
title: 'Champion',
description: 'Won a championship',
icon: 'trophy',
rarity: 'epic',
earnedAt: '2024-01-10T00:00:00Z',
},
],
racingStyle: 'Aggressive',
favoriteTrack: 'Spa',
favoriteCar: 'F3',
timezone: 'Europe/Berlin',
availableHours: 'Evenings',
lookingForTeam: false,
openToRequests: true,
},
});
describe('DriverProfileViewModel', () => {
it('exposes profile sections from DTO', () => {
const dto = createDriverProfileDto();
const viewModel = new DriverProfileViewModel(dto);
expect(viewModel.currentDriver).toEqual(dto.currentDriver);
expect(viewModel.stats).toEqual(dto.stats);
expect(viewModel.finishDistribution).toEqual(dto.finishDistribution);
expect(viewModel.teamMemberships).toEqual(dto.teamMemberships);
expect(viewModel.socialSummary).toEqual(dto.socialSummary);
expect(viewModel.extendedProfile).toEqual(dto.extendedProfile);
});
it('supports nullable sections', () => {
const dto: DriverProfileViewModel = {
...createDriverProfileDto(),
currentDriver: null,
stats: null,
finishDistribution: null,
extendedProfile: null,
};
const viewModel = new DriverProfileViewModel(dto);
expect(viewModel.currentDriver).toBeNull();
expect(viewModel.stats).toBeNull();
expect(viewModel.finishDistribution).toBeNull();
expect(viewModel.extendedProfile).toBeNull();
});
it('returns original DTO from toDTO', () => {
const dto = createDriverProfileDto();
const viewModel = new DriverProfileViewModel(dto);
expect(viewModel.toDTO()).toBe(dto);
});
});

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { DriverRegistrationStatusViewModel } from './DriverRegistrationStatusViewModel';
import type { DriverRegistrationStatusDTO } from '../types/generated/DriverRegistrationStatusDTO';
const createStatusDto = (overrides: Partial<DriverRegistrationStatusDTO> = {}): DriverRegistrationStatusDTO => ({
isRegistered: true,
raceId: 'race-1',
driverId: 'driver-1',
...overrides,
});
describe('DriverRegistrationStatusViewModel', () => {
it('maps basic registration status fields from DTO', () => {
const dto = createStatusDto({ isRegistered: true });
const viewModel = new DriverRegistrationStatusViewModel(dto);
expect(viewModel.isRegistered).toBe(true);
expect(viewModel.raceId).toBe('race-1');
expect(viewModel.driverId).toBe('driver-1');
});
it('derives UI fields when registered', () => {
const viewModel = new DriverRegistrationStatusViewModel(createStatusDto({ isRegistered: true }));
expect(viewModel.statusMessage).toBe('Registered for this race');
expect(viewModel.statusColor).toBe('green');
expect(viewModel.statusBadgeVariant).toBe('success');
expect(viewModel.registrationButtonText).toBe('Withdraw');
expect(viewModel.canRegister).toBe(false);
});
it('derives UI fields when not registered', () => {
const viewModel = new DriverRegistrationStatusViewModel(createStatusDto({ isRegistered: false }));
expect(viewModel.statusMessage).toBe('Not registered');
expect(viewModel.statusColor).toBe('red');
expect(viewModel.statusBadgeVariant).toBe('warning');
expect(viewModel.registrationButtonText).toBe('Register');
expect(viewModel.canRegister).toBe(true);
});
});

View File

@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { DriverSummaryViewModel } from './DriverSummaryViewModel';
import type { GetDriverOutputDTO } from '../types/generated/GetDriverOutputDTO';
const driverDto: GetDriverOutputDTO = {
id: 'driver-1',
iracingId: 'ir-123',
name: 'Test Driver',
country: 'DE',
joinedAt: '2024-01-01T00:00:00Z',
};
describe('DriverSummaryViewModel', () => {
it('maps driver and optional fields from DTO', () => {
const viewModel = new DriverSummaryViewModel({
driver: driverDto,
rating: 2500,
rank: 10,
});
expect(viewModel.driver).toBe(driverDto);
expect(viewModel.rating).toBe(2500);
expect(viewModel.rank).toBe(10);
});
it('defaults nullable rating and rank when undefined', () => {
const viewModel = new DriverSummaryViewModel({
driver: driverDto,
});
expect(viewModel.rating).toBeNull();
expect(viewModel.rank).toBeNull();
});
it('keeps explicit null rating and rank', () => {
const viewModel = new DriverSummaryViewModel({
driver: driverDto,
rating: null,
rank: null,
});
expect(viewModel.rating).toBeNull();
expect(viewModel.rank).toBeNull();
});
});

View File

@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { DriverTeamViewModel } from './DriverTeamViewModel';
import type { GetDriverTeamOutputDTO } from '@/lib/types/generated/GetDriverTeamOutputDTO';
const createTeamDto = (overrides: Partial<GetDriverTeamOutputDTO> = {}): GetDriverTeamOutputDTO => ({
team: {
id: 'team-1',
name: 'Test Team',
tag: 'TT',
description: 'Test team description',
ownerId: 'owner-1',
leagues: ['league-1'],
createdAt: '2024-01-01T00:00:00Z',
specialization: 'mixed',
region: 'EU',
languages: ['en'],
},
membership: {
role: 'manager',
joinedAt: '2024-01-01T00:00:00Z',
isActive: true,
},
isOwner: false,
canManage: true,
...overrides,
});
describe('DriverTeamViewModel', () => {
it('maps team and membership fields from DTO', () => {
const dto = createTeamDto();
const viewModel = new DriverTeamViewModel(dto);
expect(viewModel.teamId).toBe('team-1');
expect(viewModel.teamName).toBe('Test Team');
expect(viewModel.tag).toBe('TT');
expect(viewModel.role).toBe('manager');
expect(viewModel.isOwner).toBe(false);
expect(viewModel.canManage).toBe(true);
});
it('derives displayRole with capitalized first letter', () => {
const dto = createTeamDto({
membership: {
role: 'owner',
joinedAt: '2024-01-01T00:00:00Z',
isActive: true,
},
});
const viewModel = new DriverTeamViewModel(dto);
expect(viewModel.displayRole).toBe('Owner');
});
it('handles lower-case role strings consistently', () => {
const dto = createTeamDto({
membership: {
role: 'member',
joinedAt: '2024-01-01T00:00:00Z',
isActive: true,
},
});
const viewModel = new DriverTeamViewModel(dto);
expect(viewModel.displayRole).toBe('Member');
});
});

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { HomeDiscoveryViewModel } from './HomeDiscoveryViewModel';
import { LeagueCardViewModel } from './LeagueCardViewModel';
import { TeamCardViewModel } from './TeamCardViewModel';
import { UpcomingRaceCardViewModel } from './UpcomingRaceCardViewModel';
describe('HomeDiscoveryViewModel', () => {
it('exposes the top leagues, teams and upcoming races from the DTO', () => {
const topLeagues = [
new LeagueCardViewModel({
id: 'league-1',
name: 'Pro League',
description: 'Top-tier league',
memberCount: 100,
isMember: false,
} as any),
];
const teams = [
new TeamCardViewModel({
id: 'team-1',
name: 'Team Alpha',
tag: 'ALPHA',
memberCount: 10,
isMember: true,
} as any),
];
const upcomingRaces = [
new UpcomingRaceCardViewModel({
id: 'race-1',
track: 'Spa-Francorchamps',
car: 'GT3',
scheduledAt: '2025-01-01T12:00:00Z',
} as any),
];
const viewModel = new HomeDiscoveryViewModel({
topLeagues,
teams,
upcomingRaces,
});
expect(viewModel.topLeagues).toBe(topLeagues);
expect(viewModel.teams).toBe(teams);
expect(viewModel.upcomingRaces).toBe(upcomingRaces);
});
});

View File

@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { ImportRaceResultsSummaryViewModel } from './ImportRaceResultsSummaryViewModel';
describe('ImportRaceResultsSummaryViewModel', () => {
it('maps DTO fields including errors', () => {
const dto = {
success: true,
raceId: 'race-1',
driversProcessed: 10,
resultsRecorded: 8,
errors: ['Driver missing', 'Invalid lap time'],
};
const viewModel = new ImportRaceResultsSummaryViewModel(dto);
expect(viewModel.success).toBe(true);
expect(viewModel.raceId).toBe('race-1');
expect(viewModel.driversProcessed).toBe(10);
expect(viewModel.resultsRecorded).toBe(8);
expect(viewModel.errors).toEqual(['Driver missing', 'Invalid lap time']);
});
it('defaults errors to an empty array when not provided', () => {
const dto = {
success: false,
raceId: 'race-2',
driversProcessed: 0,
resultsRecorded: 0,
};
const viewModel = new ImportRaceResultsSummaryViewModel(dto);
expect(viewModel.errors).toEqual([]);
});
});

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { LeagueCardViewModel } from './LeagueCardViewModel';
import type { LeagueSummaryDTO } from '@/lib/types/generated/LeagueSummaryDTO';
const createSummaryDto = (overrides: Partial<LeagueSummaryDTO & { description?: string }> = {}): LeagueSummaryDTO & { description?: string } => ({
id: 'league-1',
name: 'Test League',
description: 'Custom description',
...overrides,
});
describe('LeagueCardViewModel', () => {
it('maps id, name and description from simple DTO', () => {
const dto = { id: 'simple-id', name: 'Simple League', description: 'Simple desc' };
const vm = new LeagueCardViewModel(dto);
expect(vm.id).toBe('simple-id');
expect(vm.name).toBe('Simple League');
expect(vm.description).toBe('Simple desc');
});
it('supports LeagueSummaryDTO without description and applies default description', () => {
const dto: LeagueSummaryDTO = { id: 'sum-1', name: 'Summary League' };
const vm = new LeagueCardViewModel(dto);
expect(vm.id).toBe('sum-1');
expect(vm.name).toBe('Summary League');
expect(vm.description).toBe('Competitive iRacing league');
});
it('prefers provided description when using LeagueSummaryDTO with extra description', () => {
const dto = createSummaryDto({ id: 'sum-2', name: 'Summary With Description', description: 'Provided description' });
const vm = new LeagueCardViewModel(dto);
expect(vm.id).toBe('sum-2');
expect(vm.name).toBe('Summary With Description');
expect(vm.description).toBe('Provided description');
});
it('falls back to default description when description is undefined', () => {
const dto = createSummaryDto({ description: undefined });
const vm = new LeagueCardViewModel(dto);
expect(vm.description).toBe('Competitive iRacing league');
});
});

View File

@@ -0,0 +1,234 @@
import { describe, it, expect } from 'vitest';
import { LeagueDetailPageViewModel, type SponsorInfo } from './LeagueDetailPageViewModel';
import type { LeagueWithCapacityDTO } from '../types/generated/LeagueWithCapacityDTO';
import type { LeagueStatsDTO } from '../types/generated/LeagueStatsDTO';
import type { GetDriverOutputDTO } from '../types/generated/GetDriverOutputDTO';
import type { LeagueMembershipsDTO } from '../types/generated/LeagueMembershipsDTO';
import type { LeagueScheduleDTO } from '../types/generated/LeagueScheduleDTO';
import { RaceViewModel } from './RaceViewModel';
describe('LeagueDetailPageViewModel', () => {
const league: LeagueWithCapacityDTO = {
id: 'league-1',
name: 'Pro Series',
// extra fields used by view-model but not in generated type yet
description: 'Top tier competition',
ownerId: 'owner-1',
createdAt: '2025-01-01T00:00:00Z',
maxDrivers: 40,
socialLinks: {
discordUrl: 'https://discord.gg/example',
youtubeUrl: 'https://youtube.com/example',
websiteUrl: 'https://example.com',
},
} as any;
const owner: GetDriverOutputDTO = {
id: 'owner-1',
iracingId: '100',
name: 'Owner Driver',
country: 'US',
joinedAt: '2024-01-01T00:00:00Z',
};
const drivers: GetDriverOutputDTO[] = [
owner,
{
id: 'driver-2',
iracingId: '101',
name: 'Second Driver',
country: 'DE',
joinedAt: '2024-02-01T00:00:00Z',
},
];
const memberships: LeagueMembershipsDTO = {
memberships: [
{
driverId: 'owner-1',
role: 'owner',
status: 'active',
joinedAt: '2024-01-01T00:00:00Z',
},
{
driverId: 'driver-2',
role: 'admin',
status: 'active',
joinedAt: '2024-02-01T00:00:00Z',
},
{
driverId: 'driver-3',
role: 'steward',
status: 'inactive',
joinedAt: '2024-03-01T00:00:00Z',
},
],
} as any;
const schedule: LeagueScheduleDTO = {
races: [
{ id: 'race-1', name: 'Round 1', date: '2025-01-10T20:00:00Z' },
{ id: 'race-2', name: 'Round 2', date: '2025-01-17T20:00:00Z' },
],
} as any;
const allRaces: RaceViewModel[] = schedule.races.map(r => new RaceViewModel({ ...r, views: 12000, status: 'completed' }));
const leagueStats: LeagueStatsDTO = {
totalMembers: 2,
totalRaces: 10,
averageRating: 3_200,
// view-model also expects averageSOF and completedRaces
averageSOF: 3200,
completedRaces: 4,
} as any;
const sponsors: SponsorInfo[] = [
{ id: 's1', name: 'Main Sponsor', tier: 'main', logoUrl: '/logo-main.png', tagline: 'Primary partner' },
{ id: 's2', name: 'Secondary Sponsor', tier: 'secondary', logoUrl: '/logo-sec.png' },
];
it('maps core league, owner, drivers, memberships and races', () => {
const vm = new LeagueDetailPageViewModel(
league,
owner,
null,
drivers,
memberships,
allRaces,
leagueStats,
sponsors,
);
expect(vm.id).toBe(league.id);
expect(vm.name).toBe(league.name);
expect(vm.description).toBe(league.description);
expect(vm.ownerId).toBe(league.ownerId);
expect(vm.settings.maxDrivers).toBe(league.maxDrivers);
expect(vm.socialLinks?.discordUrl).toBe(league.socialLinks?.discordUrl);
expect(vm.owner).toEqual(owner);
expect(vm.scoringConfig).toBeNull();
expect(vm.drivers).toHaveLength(drivers.length);
expect(vm.memberships).toHaveLength(memberships.memberships.length);
expect(vm.allRaces).toHaveLength(allRaces.length);
expect(vm.runningRaces.every(r => r.status === 'running')).toBe(true);
});
it('computes sponsor insights from member count and stats', () => {
const vm = new LeagueDetailPageViewModel(
league,
owner,
null,
drivers,
memberships,
allRaces,
leagueStats,
sponsors,
);
const memberCount = memberships.memberships.length;
const mainSponsorTaken = sponsors.some(s => s.tier === 'main');
const secondaryTaken = sponsors.filter(s => s.tier === 'secondary').length;
expect(vm.sponsorInsights.avgViewsPerRace).toBe(5400 + memberCount * 50);
expect(vm.sponsorInsights.totalImpressions).toBe(45000 + memberCount * 500);
expect(vm.sponsorInsights.engagementRate).toBe((3.5 + memberCount / 50).toFixed(1));
expect(vm.sponsorInsights.estimatedReach).toBe(memberCount * 150);
expect(vm.sponsorInsights.mainSponsorAvailable).toBe(!mainSponsorTaken);
expect(vm.sponsorInsights.secondarySlotsAvailable).toBe(Math.max(0, 2 - secondaryTaken));
expect(vm.sponsorInsights.mainSponsorPrice).toBe(800 + Math.floor(memberCount * 10));
expect(vm.sponsorInsights.secondaryPrice).toBe(250 + Math.floor(memberCount * 3));
expect(vm.sponsorInsights.discordMembers).toBe(memberCount * 3);
expect(vm.sponsorInsights.monthlyActivity).toBe(Math.min(100, 40 + (leagueStats as any).completedRaces * 2));
});
it('derives sponsor tier and trustScore from averageSOF and completedRaces', () => {
const highSofStats = { ...leagueStats, averageSOF: 3500 } as any;
const vmHigh = new LeagueDetailPageViewModel(
league,
owner,
null,
drivers,
memberships,
allRaces,
highSofStats,
sponsors,
);
expect(vmHigh.sponsorInsights.tier).toBe('premium');
const midSofStats = { ...leagueStats, averageSOF: 2500 } as any;
const vmMid = new LeagueDetailPageViewModel(
league,
owner,
null,
drivers,
memberships,
allRaces,
midSofStats,
sponsors,
);
expect(vmMid.sponsorInsights.tier).toBe('standard');
const lowSofStats = { ...leagueStats, averageSOF: 1500 } as any;
const vmLow = new LeagueDetailPageViewModel(
league,
owner,
null,
drivers,
memberships,
allRaces,
lowSofStats,
sponsors,
);
expect(vmLow.sponsorInsights.tier).toBe('starter');
expect(vmHigh.sponsorInsights.trustScore).toBe(
Math.min(100, 60 + memberships.memberships.length + (leagueStats as any).completedRaces),
);
});
it('builds owner, admin and steward summaries from memberships', () => {
const vm = new LeagueDetailPageViewModel(
league,
owner,
null,
drivers,
memberships,
allRaces,
leagueStats,
sponsors,
);
expect(vm.ownerSummary?.driver.id).toBe('owner-1');
expect(vm.ownerSummary?.rating).toBeNull();
expect(vm.ownerSummary?.rank).toBeNull();
expect(vm.adminSummaries.length).toBeGreaterThanOrEqual(1);
expect(vm.adminSummaries[0].driver.id).toBe('driver-2');
expect(Array.isArray(vm.stewardSummaries)).toBe(true);
});
it('returns falsey sponsor/membership helpers by default', () => {
const vm = new LeagueDetailPageViewModel(
league,
owner,
null,
drivers,
memberships,
allRaces,
leagueStats,
sponsors,
);
expect(vm.isSponsorMode).toBe(false);
expect(vm.currentUserMembership).toBeNull();
expect(vm.canEndRaces).toBe(false);
});
});

View File

@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest';
import { LeagueDetailViewModel, LeagueViewModel, DriverViewModel, RaceViewModel } from './LeagueDetailViewModel';
describe('LeagueDetailViewModel', () => {
const baseLeague = {
id: 'league-1',
name: 'Pro Series',
game: 'iRacing',
tier: 'premium' as const,
season: '2025 Season 1',
description: 'High level competition',
drivers: 24,
races: 10,
completedRaces: 4,
totalImpressions: 100_000,
avgViewsPerRace: 10_000,
engagement: 3.5,
rating: 4.7,
seasonStatus: 'active' as const,
seasonDates: { start: '2025-01-01', end: '2025-04-01' },
nextRace: { name: 'Round 5', date: '2025-02-01' },
sponsorSlots: {
main: { available: true, price: 5_000, benefits: ['Branding', 'Mentions'] },
secondary: { available: 2, total: 3, price: 2_000, benefits: ['Branding'] },
},
};
const drivers = [
{ id: 'd1', name: 'Alice', country: 'US', position: 1, races: 4, impressions: 30_000, team: 'Alpha' },
{ id: 'd2', name: 'Bob', country: 'DE', position: 2, races: 4, impressions: 25_000, team: 'Beta' },
];
const races = [
{ id: 'r1', name: 'Round 1', date: '2025-01-01', views: 12_000, status: 'completed' as const },
{ id: 'r2', name: 'Round 2', date: '2025-01-08', views: 11_000, status: 'upcoming' as const },
];
it('maps nested league, drivers and races into view models', () => {
const vm = new LeagueDetailViewModel({ league: baseLeague, drivers, races });
expect(vm.league).toBeInstanceOf(LeagueViewModel);
expect(vm.drivers).toHaveLength(2);
expect(vm.drivers[0]).toBeInstanceOf(DriverViewModel);
expect(vm.races).toHaveLength(2);
expect(vm.races[0]).toBeInstanceOf(RaceViewModel);
});
it('keeps core league metrics available on LeagueViewModel', () => {
const vm = new LeagueDetailViewModel({ league: baseLeague, drivers, races });
expect(vm.league.id).toBe('league-1');
expect(vm.league.drivers).toBe(24);
expect(vm.league.races).toBe(10);
expect(vm.league.completedRaces).toBe(4);
expect(vm.league.totalImpressions).toBe(100_000);
expect(vm.league.avgViewsPerRace).toBe(10_000);
});
it('exposes formatted impression and views statistics', () => {
const vm = new LeagueDetailViewModel({ league: baseLeague, drivers, races });
expect(vm.league.formattedTotalImpressions).toBe(baseLeague.totalImpressions.toLocaleString());
expect(vm.league.formattedAvgViewsPerRace).toBe(baseLeague.avgViewsPerRace.toLocaleString());
expect(vm.league.projectedTotalViews).toBe(baseLeague.avgViewsPerRace * baseLeague.races);
expect(vm.league.formattedProjectedTotal).toBe(vm.league.projectedTotalViews.toLocaleString());
});
it('calculates CPM for main sponsor from projected total views', () => {
const vm = new LeagueDetailViewModel({ league: baseLeague, drivers, races });
const expectedCpm = Math.round((baseLeague.sponsorSlots.main.price / vm.league.projectedTotalViews) * 1000);
expect(vm.league.mainSponsorCpm).toBe(expectedCpm);
expect(vm.league.formattedMainSponsorCpm).toBe(`$${expectedCpm.toFixed(2)}`);
});
it('derives races left and tier configuration', () => {
const vm = new LeagueDetailViewModel({ league: baseLeague, drivers, races });
expect(vm.league.racesLeft).toBe(baseLeague.races - baseLeague.completedRaces);
const tierConfig = vm.league.tierConfig;
expect(tierConfig.color).toBeDefined();
expect(tierConfig.bgColor).toBeDefined();
expect(tierConfig.border).toBeDefined();
});
it('formats driver impressions and race date/views', () => {
const vm = new LeagueDetailViewModel({ league: baseLeague, drivers, races });
expect(vm.drivers[0].formattedImpressions).toBe(drivers[0].impressions.toLocaleString());
// formattedDate uses a short, locale-specific date string like "Jan 1"
const formattedDate = vm.races[0].formattedDate;
expect(typeof formattedDate).toBe('string');
expect(formattedDate.length).toBeGreaterThan(0);
expect(vm.races[0].formattedViews).toBe(races[0].views.toLocaleString());
});
});

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { LeagueJoinRequestViewModel } from './LeagueJoinRequestViewModel';
import type { LeagueJoinRequestDTO } from '../types/generated/LeagueJoinRequestDTO';
const createLeagueJoinRequestDto = (overrides: Partial<LeagueJoinRequestDTO> = {}): LeagueJoinRequestDTO => ({
id: 'request-1',
leagueId: 'league-1',
driverId: 'driver-1',
requestedAt: '2024-01-01T12:00:00Z',
...overrides,
});
describe('LeagueJoinRequestViewModel', () => {
it('maps fields from DTO', () => {
const dto = createLeagueJoinRequestDto({ id: 'req-123', driverId: 'driver-123' });
const vm = new LeagueJoinRequestViewModel(dto, 'current-user', true);
expect(vm.id).toBe('req-123');
expect(vm.leagueId).toBe('league-1');
expect(vm.driverId).toBe('driver-123');
expect(vm.requestedAt).toBe('2024-01-01T12:00:00Z');
});
it('formats requestedAt as a localized date-time string', () => {
const dto = createLeagueJoinRequestDto({ requestedAt: '2024-01-01T12:00:00Z' });
const vm = new LeagueJoinRequestViewModel(dto, 'current-user', true);
const formatted = vm.formattedRequestedAt;
expect(typeof formatted).toBe('string');
expect(formatted.length).toBeGreaterThan(0);
});
it('allows approval and rejection only for admins', () => {
const dto = createLeagueJoinRequestDto();
const adminVm = new LeagueJoinRequestViewModel(dto, 'admin-user', true);
const nonAdminVm = new LeagueJoinRequestViewModel(dto, 'regular-user', false);
expect(adminVm.canApprove).toBe(true);
expect(adminVm.canReject).toBe(true);
expect(nonAdminVm.canApprove).toBe(false);
expect(nonAdminVm.canReject).toBe(false);
});
});

View File

@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest';
import { LeagueMemberViewModel } from './LeagueMemberViewModel';
import type { LeagueMemberDTO } from '../types/generated/LeagueMemberDTO';
const createLeagueMemberDto = (overrides: Partial<LeagueMemberDTO> = {}): LeagueMemberDTO => ({
driverId: 'driver-1',
leagueId: 'league-1',
joinedAt: '2024-01-01T00:00:00Z',
role: 'member',
...overrides,
});
describe('LeagueMemberViewModel', () => {
it('maps basic fields from DTO and sets defaults', () => {
const dto = createLeagueMemberDto({ driverId: 'driver-123' });
const vm = new LeagueMemberViewModel(dto, 'current-user');
expect(vm.driverId).toBe('driver-123');
expect(vm.role).toBe('member');
expect(typeof vm.joinedAt).toBe('string');
});
it('computes formattedJoinedAt as a localized date string', () => {
const dto = createLeagueMemberDto({ joinedAt: '2024-01-01T00:00:00Z' });
const vm = new LeagueMemberViewModel(dto, 'current-user');
const formatted = vm.formattedJoinedAt;
expect(typeof formatted).toBe('string');
expect(formatted.length).toBeGreaterThan(0);
});
it('derives roleBadgeVariant based on current role value', () => {
const vm = new LeagueMemberViewModel(createLeagueMemberDto(), 'current-user');
vm.role = 'owner';
expect(vm.roleBadgeVariant).toBe('primary');
vm.role = 'admin';
expect(vm.roleBadgeVariant).toBe('secondary');
vm.role = 'member';
expect(vm.roleBadgeVariant).toBe('default');
vm.role = 'something-else' as any;
expect(vm.roleBadgeVariant).toBe('default');
});
it('identifies owner and current user correctly based on role and driverId', () => {
const dto = createLeagueMemberDto({ driverId: 'driver-1' });
const vmForOwner = new LeagueMemberViewModel(dto, 'driver-1');
vmForOwner.role = 'owner';
const vmForAnotherUser = new LeagueMemberViewModel(dto, 'driver-2');
vmForAnotherUser.role = 'owner';
expect(vmForOwner.isOwner).toBe(true);
expect(vmForOwner.isCurrentUser).toBe(true);
expect(vmForAnotherUser.isOwner).toBe(true);
expect(vmForAnotherUser.isCurrentUser).toBe(false);
});
});

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { LeagueMembershipsViewModel } from './LeagueMembershipsViewModel';
import { LeagueMemberViewModel } from './LeagueMemberViewModel';
import type { LeagueMemberDTO } from '../types/generated/LeagueMemberDTO';
const createMembershipDto = (overrides: Partial<LeagueMemberDTO> = {}): LeagueMemberDTO => ({
driverId: 'driver-1',
leagueId: 'league-1',
joinedAt: '2024-01-01T00:00:00Z',
role: 'member',
...overrides,
});
describe('LeagueMembershipsViewModel', () => {
it('maps memberships into LeagueMemberViewModel instances', () => {
const dto = {
memberships: [
createMembershipDto({ driverId: 'driver-1' }),
createMembershipDto({ driverId: 'driver-2' }),
],
};
const viewModel = new LeagueMembershipsViewModel(dto, 'driver-1');
expect(viewModel.memberships).toHaveLength(2);
expect(viewModel.memberships[0]).toBeInstanceOf(LeagueMemberViewModel);
expect(viewModel.memberships[0].driverId).toBe('driver-1');
expect(viewModel.memberships[1].driverId).toBe('driver-2');
});
it('computes memberCount and hasMembers correctly when there are members', () => {
const dto = {
memberships: [
createMembershipDto({ driverId: 'driver-1' }),
createMembershipDto({ driverId: 'driver-2' }),
],
};
const viewModel = new LeagueMembershipsViewModel(dto, 'driver-1');
expect(viewModel.memberCount).toBe(2);
expect(viewModel.hasMembers).toBe(true);
});
it('computes memberCount and hasMembers correctly when empty', () => {
const dto = {
memberships: [] as LeagueMemberDTO[],
};
const viewModel = new LeagueMembershipsViewModel(dto, 'driver-1');
expect(viewModel.memberCount).toBe(0);
expect(viewModel.hasMembers).toBe(false);
});
});

View File

@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { LeagueScheduleViewModel } from './LeagueScheduleViewModel';
describe('LeagueScheduleViewModel', () => {
it('maps races array from DTO', () => {
const races = [{ id: 'race-1' }, { id: 'race-2' }];
const vm = new LeagueScheduleViewModel({ races });
expect(vm.races).toBe(races);
expect(vm.raceCount).toBe(2);
});
it('derives hasRaces correctly for non-empty schedule', () => {
const races = [{ id: 'race-1' }];
const vm = new LeagueScheduleViewModel({ races });
expect(vm.raceCount).toBe(1);
expect(vm.hasRaces).toBe(true);
});
it('derives hasRaces correctly for empty schedule', () => {
const vm = new LeagueScheduleViewModel({ races: [] });
expect(vm.raceCount).toBe(0);
expect(vm.hasRaces).toBe(false);
});
});

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { LeagueScoringPresetsViewModel } from './LeagueScoringPresetsViewModel';
import type { LeagueScoringPresetDTO } from '@core/racing/application/ports/LeagueScoringPresetProvider';
const createPreset = (overrides: Partial<LeagueScoringPresetDTO> = {}): LeagueScoringPresetDTO => ({
id: 'preset-1',
name: 'Standard scoring',
description: 'Top 15 get points',
gameId: 'iracing',
...overrides,
} as LeagueScoringPresetDTO);
describe('LeagueScoringPresetsViewModel', () => {
it('maps presets array from DTO', () => {
const presets = [createPreset(), createPreset({ id: 'preset-2', name: 'Alt scoring' })];
const vm = new LeagueScoringPresetsViewModel({ presets });
expect(vm.presets).toBe(presets);
expect(vm.totalCount).toBe(2);
});
it('uses explicit totalCount when provided', () => {
const presets = [createPreset(), createPreset()];
const vm = new LeagueScoringPresetsViewModel({ presets, totalCount: 10 });
expect(vm.presets).toBe(presets);
expect(vm.totalCount).toBe(10);
});
it('handles empty presets array', () => {
const vm = new LeagueScoringPresetsViewModel({ presets: [] });
expect(vm.presets).toEqual([]);
expect(vm.totalCount).toBe(0);
});
});

View File

@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { LeagueSettingsViewModel } from './LeagueSettingsViewModel';
import { DriverSummaryViewModel } from './DriverSummaryViewModel';
import type { LeagueConfigFormModel } from '@core/racing/application';
import type { LeagueScoringPresetDTO } from '@core/racing/application/ports/LeagueScoringPresetProvider';
const createConfig = (overrides: Partial<LeagueConfigFormModel> = {}): LeagueConfigFormModel => ({
name: 'Pro League',
description: 'Top tier competition',
maxDrivers: 40,
maxTeams: 10,
...overrides,
} as LeagueConfigFormModel);
const createPreset = (overrides: Partial<LeagueScoringPresetDTO> = {}): LeagueScoringPresetDTO => ({
id: 'preset-1',
name: 'Standard scoring',
description: 'Top 15 get points',
gameId: 'iracing',
...overrides,
} as LeagueScoringPresetDTO);
const createDriverSummary = (id: string, name: string): DriverSummaryViewModel =>
new DriverSummaryViewModel({
driver: {
id,
iracingId: `ir-${id}`,
name,
country: 'DE',
joinedAt: '2024-01-01T00:00:00Z',
},
});
describe('LeagueSettingsViewModel', () => {
it('maps league, config, presets, owner and members from DTO', () => {
const league = { id: 'league-1', name: 'Pro League', ownerId: 'owner-1' };
const config = createConfig();
const presets = [createPreset(), createPreset({ id: 'preset-2', name: 'Alt scoring' })];
const owner = createDriverSummary('driver-1', 'Owner Driver');
const members = [createDriverSummary('driver-2', 'Member One'), createDriverSummary('driver-3', 'Member Two')];
const vm = new LeagueSettingsViewModel({ league, config, presets, owner, members });
expect(vm.league).toBe(league);
expect(vm.config).toBe(config);
expect(vm.presets).toBe(presets);
expect(vm.owner).toBe(owner);
expect(vm.members).toBe(members);
});
it('allows null owner while keeping members and presets', () => {
const league = { id: 'league-1', name: 'Pro League', ownerId: 'owner-1' };
const config = createConfig();
const presets = [createPreset()];
const members = [createDriverSummary('driver-2', 'Member One')];
const vm = new LeagueSettingsViewModel({ league, config, presets, owner: null, members });
expect(vm.owner).toBeNull();
expect(vm.members).toHaveLength(1);
expect(vm.presets).toHaveLength(1);
});
});

View File

@@ -24,7 +24,7 @@ describe('LeagueStandingsViewModel', () => {
];
const viewModel = new LeagueStandingsViewModel(
{ standings },
{ standings, drivers: [], memberships: [] },
'driver-1'
);
@@ -46,7 +46,7 @@ describe('LeagueStandingsViewModel', () => {
];
const viewModel = new LeagueStandingsViewModel(
{ standings },
{ standings, drivers: [], memberships: [] },
'driver-1'
);
@@ -82,7 +82,7 @@ describe('LeagueStandingsViewModel', () => {
];
const viewModel = new LeagueStandingsViewModel(
{ standings },
{ standings, drivers: [], memberships: [] },
'driver-2'
);
@@ -111,7 +111,7 @@ describe('LeagueStandingsViewModel', () => {
];
const viewModel = new LeagueStandingsViewModel(
{ standings },
{ standings, drivers: [], memberships: [] },
'driver-2'
);
@@ -121,7 +121,7 @@ describe('LeagueStandingsViewModel', () => {
it('should handle empty standings', () => {
const viewModel = new LeagueStandingsViewModel(
{ standings: [] },
{ standings: [], drivers: [], memberships: [] },
'driver-1'
);
@@ -168,7 +168,7 @@ describe('LeagueStandingsViewModel', () => {
];
const viewModel = new LeagueStandingsViewModel(
{ standings },
{ standings, drivers: [], memberships: [] },
'driver-1',
previousStandings
);

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { LeagueStatsViewModel } from './LeagueStatsViewModel';
const createDto = (overrides: Partial<{ totalLeagues: number }> = {}): { totalLeagues: number } => ({
totalLeagues: 1234,
...overrides,
});
describe('LeagueStatsViewModel', () => {
it('maps totalLeagues from DTO', () => {
const dto = createDto({ totalLeagues: 42 });
const vm = new LeagueStatsViewModel(dto);
expect(vm.totalLeagues).toBe(42);
});
it('formats totalLeagues using locale separators', () => {
const dto = createDto({ totalLeagues: 12345 });
const vm = new LeagueStatsViewModel(dto);
expect(vm.formattedTotalLeagues).toBe((12345).toLocaleString());
});
it('handles zero leagues correctly', () => {
const dto = createDto({ totalLeagues: 0 });
const vm = new LeagueStatsViewModel(dto);
expect(vm.totalLeagues).toBe(0);
expect(vm.formattedTotalLeagues).toBe('0');
});
});

View File

@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import { LeagueStewardingViewModel, type RaceWithProtests, type Protest, type Penalty } from './LeagueStewardingViewModel';
const createProtest = (id: string, status: string): Protest => ({
id,
protestingDriverId: `pd-${id}`,
accusedDriverId: `ad-${id}`,
incident: { lap: 1, description: 'Test incident' },
filedAt: '2024-01-01T21:00:00Z',
status,
});
const createPenalty = (id: string): Penalty => ({
id,
driverId: `d-${id}`,
type: 'time',
value: 5,
reason: 'Test penalty',
});
const createRaceWithProtests = (id: string, pendingCount: number, resolvedCount: number, penaltiesCount: number): RaceWithProtests => ({
race: {
id,
track: 'Spa-Francorchamps',
scheduledAt: new Date('2024-01-01T20:00:00Z'),
},
pendingProtests: Array.from({ length: pendingCount }, (_, index) => createProtest(`${id}-pending-${index + 1}`, 'pending')),
resolvedProtests: Array.from({ length: resolvedCount }, (_, index) => createProtest(`${id}-resolved-${index + 1}`, 'upheld')),
penalties: Array.from({ length: penaltiesCount }, (_, index) => createPenalty(`${id}-pen-${index + 1}`)),
});
describe('LeagueStewardingViewModel', () => {
it('aggregates totals across all races', () => {
const races: RaceWithProtests[] = [
createRaceWithProtests('race-1', 2, 1, 1),
createRaceWithProtests('race-2', 1, 3, 2),
createRaceWithProtests('race-3', 0, 0, 0),
];
const driverMap = {
d1: { id: 'd1', name: 'Driver 1' },
d2: { id: 'd2', name: 'Driver 2' },
};
const viewModel = new LeagueStewardingViewModel(races, driverMap);
expect(viewModel.totalPending).toBe(3);
expect(viewModel.totalResolved).toBe(4);
expect(viewModel.totalPenalties).toBe(3);
});
it('filters races into pending and history buckets', () => {
const races: RaceWithProtests[] = [
createRaceWithProtests('race-1', 2, 0, 0), // pending only
createRaceWithProtests('race-2', 0, 3, 0), // history only (resolved)
createRaceWithProtests('race-3', 0, 0, 2), // history only (penalties)
createRaceWithProtests('race-4', 0, 0, 0), // no activity
];
const viewModel = new LeagueStewardingViewModel(races, {});
const pendingIds = viewModel.pendingRaces.map(r => r.race.id).sort();
const historyIds = viewModel.historyRaces.map(r => r.race.id).sort();
expect(pendingIds).toEqual(['race-1']);
expect(historyIds).toEqual(['race-2', 'race-3']);
});
it('exposes all drivers as a flat array', () => {
const races: RaceWithProtests[] = [createRaceWithProtests('race-1', 0, 0, 0)];
const driverMap = {
d1: { id: 'd1', name: 'Driver 1', avatarUrl: 'avatar-1.png' },
d2: { id: 'd2', name: 'Driver 2', iracingId: 'ir-2' },
};
const viewModel = new LeagueStewardingViewModel(races, driverMap);
expect(viewModel.allDrivers).toHaveLength(2);
expect(viewModel.allDrivers).toEqual([
driverMap.d1,
driverMap.d2,
]);
});
it('handles an empty dataset gracefully', () => {
const viewModel = new LeagueStewardingViewModel([], {});
expect(viewModel.totalPending).toBe(0);
expect(viewModel.totalResolved).toBe(0);
expect(viewModel.totalPenalties).toBe(0);
expect(viewModel.pendingRaces).toEqual([]);
expect(viewModel.historyRaces).toEqual([]);
expect(viewModel.allDrivers).toEqual([]);
});
});

View File

@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import type { LeagueSummaryViewModel } from './LeagueSummaryViewModel';
describe('LeagueSummaryViewModel shape', () => {
const createSummary = (overrides: Partial<LeagueSummaryViewModel> = {}): LeagueSummaryViewModel => ({
id: 'league-1',
name: 'Test League',
description: 'A test league',
ownerId: 'owner-1',
createdAt: '2025-01-01T00:00:00Z',
maxDrivers: 40,
usedDriverSlots: 10,
maxTeams: 10,
usedTeamSlots: 4,
structureSummary: 'Team-based sprint series',
scoringPatternSummary: 'Top 15 points, 1 drop',
timingSummary: 'Weekly, Wednesdays 20:00 UTC',
scoring: {
gameId: 'iracing',
gameName: 'iRacing',
primaryChampionshipType: 'driver',
scoringPresetId: 'preset-1',
scoringPresetName: 'Standard scoring',
dropPolicySummary: '1 drop race',
scoringPatternSummary: 'Top 15 receive points',
},
...overrides,
});
it('accepts basic league summary structure', () => {
const summary: LeagueSummaryViewModel = createSummary();
expect(summary.id).toBe('league-1');
expect(summary.name).toBe('Test League');
expect(summary.structureSummary).toBeDefined();
expect(summary.timingSummary).toBeDefined();
});
it('allows optional team and scoring fields to be omitted', () => {
const summary: LeagueSummaryViewModel = createSummary({
maxTeams: undefined,
usedTeamSlots: undefined,
scoring: undefined,
});
expect(summary.maxTeams).toBeUndefined();
expect(summary.usedTeamSlots).toBeUndefined();
expect(summary.scoring).toBeUndefined();
});
it('supports detailed scoring configuration when present', () => {
const summary: LeagueSummaryViewModel = createSummary();
expect(summary.scoring?.gameId).toBe('iracing');
expect(summary.scoring?.primaryChampionshipType).toBe('driver');
expect(summary.scoring?.dropPolicySummary).toContain('drop');
expect(summary.scoring?.scoringPatternSummary).toContain('Top 15');
});
});

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { LeagueWalletViewModel } from './LeagueWalletViewModel';
import { WalletTransactionViewModel } from './WalletTransactionViewModel';
const createTransaction = (overrides: Partial<WalletTransactionViewModel> = {}): WalletTransactionViewModel =>
new WalletTransactionViewModel({
id: 'tx-1',
type: 'sponsorship',
description: 'Test transaction',
amount: 100,
fee: 10,
netAmount: 90,
date: new Date('2024-01-01T00:00:00Z'),
status: 'completed',
reference: 'ref-1',
...(overrides as any),
});
describe('LeagueWalletViewModel', () => {
it('maps core wallet fields from DTO', () => {
const transactions = [createTransaction()];
const vm = new LeagueWalletViewModel({
balance: 250.5,
currency: 'USD',
totalRevenue: 1000,
totalFees: 50,
totalWithdrawals: 200,
pendingPayouts: 75,
transactions,
canWithdraw: true,
withdrawalBlockReason: undefined,
});
expect(vm.balance).toBe(250.5);
expect(vm.currency).toBe('USD');
expect(vm.totalRevenue).toBe(1000);
expect(vm.totalFees).toBe(50);
expect(vm.totalWithdrawals).toBe(200);
expect(vm.pendingPayouts).toBe(75);
expect(vm.transactions).toBe(transactions);
expect(vm.canWithdraw).toBe(true);
expect(vm.withdrawalBlockReason).toBeUndefined();
});
it('formats monetary fields as currency strings', () => {
const vm = new LeagueWalletViewModel({
balance: 250.5,
currency: 'USD',
totalRevenue: 1234.56,
totalFees: 78.9,
totalWithdrawals: 0,
pendingPayouts: 42,
transactions: [],
canWithdraw: false,
});
expect(vm.formattedBalance).toBe('$250.50');
expect(vm.formattedTotalRevenue).toBe('$1234.56');
expect(vm.formattedTotalFees).toBe('$78.90');
expect(vm.formattedPendingPayouts).toBe('$42.00');
});
it('filters transactions by type and supports all', () => {
const sponsorshipTx = createTransaction({ type: 'sponsorship' as any });
const membershipTx = createTransaction({ type: 'membership' as any, id: 'tx-2' });
const withdrawalTx = createTransaction({ type: 'withdrawal' as any, id: 'tx-3' });
const prizeTx = createTransaction({ type: 'prize' as any, id: 'tx-4' });
const vm = new LeagueWalletViewModel({
balance: 0,
currency: 'USD',
totalRevenue: 0,
totalFees: 0,
totalWithdrawals: 0,
pendingPayouts: 0,
transactions: [sponsorshipTx, membershipTx, withdrawalTx, prizeTx],
canWithdraw: false,
});
expect(vm.getFilteredTransactions('all')).toHaveLength(4);
expect(vm.getFilteredTransactions('sponsorship')).toEqual([sponsorshipTx]);
expect(vm.getFilteredTransactions('membership')).toEqual([membershipTx]);
expect(vm.getFilteredTransactions('withdrawal')).toEqual([withdrawalTx]);
expect(vm.getFilteredTransactions('prize')).toEqual([prizeTx]);
});
});

View File

@@ -65,10 +65,10 @@ describe('MediaViewModel', () => {
url: 'https://example.com/image.jpg',
type: 'image',
uploadedAt: new Date().toISOString(),
size: 2048000, // 2 MB
size: 2048000, // 2 MB in base-10, ~1.95 MB in base-2
});
expect(viewModel.formattedSize).toBe('2.00 MB');
expect(viewModel.formattedSize).toBe('1.95 MB');
});
it('should handle very small file sizes', () => {

View File

@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { MembershipFeeViewModel } from './MembershipFeeViewModel';
import type { MembershipFeeDto } from '../types/generated';
const createMembershipFeeDto = (overrides: Partial<MembershipFeeDto> = {}): MembershipFeeDto => ({
id: 'fee-1',
leagueId: 'league-1',
seasonId: 'season-1',
type: 'season',
amount: 49.99,
enabled: true,
createdAt: new Date('2024-01-01T10:00:00Z'),
updatedAt: new Date('2024-01-02T10:00:00Z'),
...overrides,
});
describe('MembershipFeeViewModel', () => {
it('maps fields from DTO', () => {
const dto = createMembershipFeeDto({ id: 'custom-fee', amount: 19.5 });
const vm = new MembershipFeeViewModel(dto);
expect(vm.id).toBe('custom-fee');
expect(vm.leagueId).toBe('league-1');
expect(vm.seasonId).toBe('season-1');
expect(vm.amount).toBe(19.5);
expect(vm.enabled).toBe(true);
});
it('formats amount with euro currency and two decimals', () => {
const dto = createMembershipFeeDto({ amount: 10 });
const vm = new MembershipFeeViewModel(dto);
expect(vm.formattedAmount).toBe('€10.00');
});
it('maps type to human-readable display', () => {
const seasonVm = new MembershipFeeViewModel(createMembershipFeeDto({ type: 'season' }));
const monthlyVm = new MembershipFeeViewModel(createMembershipFeeDto({ type: 'monthly' }));
const perRaceVm = new MembershipFeeViewModel(createMembershipFeeDto({ type: 'per_race' }));
const otherVm = new MembershipFeeViewModel(createMembershipFeeDto({ type: 'custom' as any }));
expect(seasonVm.typeDisplay).toBe('Per Season');
expect(monthlyVm.typeDisplay).toBe('Monthly');
expect(perRaceVm.typeDisplay).toBe('Per Race');
expect(otherVm.typeDisplay).toBe('custom');
});
it('derives statusDisplay and statusColor from enabled flag', () => {
const enabledVm = new MembershipFeeViewModel(createMembershipFeeDto({ enabled: true }));
const disabledVm = new MembershipFeeViewModel(createMembershipFeeDto({ enabled: false }));
expect(enabledVm.statusDisplay).toBe('Enabled');
expect(enabledVm.statusColor).toBe('green');
expect(disabledVm.statusDisplay).toBe('Disabled');
expect(disabledVm.statusColor).toBe('red');
});
it('formats createdAt and updatedAt as localized strings', () => {
const dto = createMembershipFeeDto({
createdAt: new Date('2024-01-01T10:00:00Z'),
updatedAt: new Date('2024-01-02T10:00:00Z'),
});
const vm = new MembershipFeeViewModel(dto);
expect(typeof vm.formattedCreatedAt).toBe('string');
expect(vm.formattedCreatedAt.length).toBeGreaterThan(0);
expect(typeof vm.formattedUpdatedAt).toBe('string');
expect(vm.formattedUpdatedAt.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { PaymentViewModel } from './PaymentViewModel';
const createPaymentDto = (overrides: Partial<any> = {}): any => ({
id: 'pay-1',
type: 'membership_fee',
amount: 100.5,
platformFee: 10.5,
netAmount: 90,
payerId: 'sponsor-1',
payerType: 'sponsor',
leagueId: 'league-1',
seasonId: 'season-1',
status: 'completed',
createdAt: new Date('2024-01-01T10:00:00Z'),
completedAt: new Date('2024-01-01T11:00:00Z'),
...overrides,
});
describe('PaymentViewModel', () => {
it('maps DTO fields via Object.assign', () => {
const dto = createPaymentDto();
const vm = new PaymentViewModel(dto);
expect(vm.id).toBe('pay-1');
expect(vm.amount).toBe(100.5);
expect(vm.netAmount).toBe(90);
expect(vm.payerId).toBe('sponsor-1');
expect(vm.leagueId).toBe('league-1');
});
it('formats amount and netAmount as EUR currency strings', () => {
const vm = new PaymentViewModel(createPaymentDto({ amount: 50, netAmount: 40 }));
expect(vm.formattedAmount).toBe('€50.00');
expect(vm.formattedNetAmount).toBe('€40.00');
});
it('maps status to a statusColor', () => {
const completed = new PaymentViewModel(createPaymentDto({ status: 'completed' }));
const pending = new PaymentViewModel(createPaymentDto({ status: 'pending' }));
const failed = new PaymentViewModel(createPaymentDto({ status: 'failed' }));
const refunded = new PaymentViewModel(createPaymentDto({ status: 'refunded' }));
const other = new PaymentViewModel(createPaymentDto({ status: 'unknown' }));
expect(completed.statusColor).toBe('green');
expect(pending.statusColor).toBe('yellow');
expect(failed.statusColor).toBe('red');
expect(refunded.statusColor).toBe('orange');
expect(other.statusColor).toBe('gray');
});
it('formats createdAt and completedAt using locale formatting', () => {
const vm = new PaymentViewModel(createPaymentDto());
expect(typeof vm.formattedCreatedAt).toBe('string');
expect(typeof vm.formattedCompletedAt).toBe('string');
});
it('handles missing completedAt with fallback text', () => {
const vm = new PaymentViewModel(createPaymentDto({ completedAt: undefined }));
expect(vm.formattedCompletedAt).toBe('Not completed');
});
it('derives display labels for status, type and payer type', () => {
const vm = new PaymentViewModel(createPaymentDto({ status: 'pending', type: 'membership_fee', payerType: 'league' }));
expect(vm.statusDisplay).toBe('Pending');
expect(vm.typeDisplay).toBe('Membership Fee');
expect(vm.payerTypeDisplay).toBe('League');
});
});

View File

@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { PrizeViewModel } from './PrizeViewModel';
const createPrizeDto = (overrides: Partial<any> = {}): any => ({
id: 'prize-1',
leagueId: 'league-1',
seasonId: 'season-1',
position: 1,
name: 'Main Prize',
amount: 100,
type: 'cash',
description: 'Top prize',
awarded: false,
awardedTo: undefined,
awardedAt: undefined,
createdAt: new Date('2024-01-01T00:00:00Z'),
...overrides,
});
describe('PrizeViewModel', () => {
it('maps DTO fields via Object.assign', () => {
const dto = createPrizeDto();
const vm = new PrizeViewModel(dto);
expect(vm.id).toBe('prize-1');
expect(vm.leagueId).toBe('league-1');
expect(vm.position).toBe(1);
expect(vm.amount).toBe(100);
});
it('formats amount as EUR currency string', () => {
const vm = new PrizeViewModel(createPrizeDto({ amount: 50.25 }));
expect(vm.formattedAmount).toBe('€50.25');
});
it('formats positionDisplay based on position', () => {
const first = new PrizeViewModel(createPrizeDto({ position: 1 }));
const second = new PrizeViewModel(createPrizeDto({ position: 2 }));
const third = new PrizeViewModel(createPrizeDto({ position: 3 }));
const other = new PrizeViewModel(createPrizeDto({ position: 4 }));
expect(first.positionDisplay).toBe('1st Place');
expect(second.positionDisplay).toBe('2nd Place');
expect(third.positionDisplay).toBe('3rd Place');
expect(other.positionDisplay).toBe('4th Place');
});
it('maps type to human readable typeDisplay', () => {
const cash = new PrizeViewModel(createPrizeDto({ type: 'cash' }));
const merchandise = new PrizeViewModel(createPrizeDto({ type: 'merchandise' }));
const other = new PrizeViewModel(createPrizeDto({ type: 'other' }));
const unknown = new PrizeViewModel(createPrizeDto({ type: 'custom' }));
expect(cash.typeDisplay).toBe('Cash Prize');
expect(merchandise.typeDisplay).toBe('Merchandise');
expect(other.typeDisplay).toBe('Other');
expect(unknown.typeDisplay).toBe('custom');
});
it('derives statusDisplay and statusColor from awarded flag', () => {
const available = new PrizeViewModel(createPrizeDto({ awarded: false }));
const awarded = new PrizeViewModel(createPrizeDto({ awarded: true }));
expect(available.statusDisplay).toBe('Available');
expect(available.statusColor).toBe('blue');
expect(awarded.statusDisplay).toBe('Awarded');
expect(awarded.statusColor).toBe('green');
});
it('builds prizeDescription from name and amount', () => {
const vm = new PrizeViewModel(createPrizeDto({ name: 'Bonus', amount: 25 }));
expect(vm.prizeDescription).toBe('Bonus - €25.00');
});
it('formats awardedAt and createdAt timestamps', () => {
const awardedAt = new Date('2024-01-02T00:00:00Z');
const vm = new PrizeViewModel(createPrizeDto({ awarded: true, awardedAt }));
expect(typeof vm.formattedAwardedAt).toBe('string');
expect(typeof vm.formattedCreatedAt).toBe('string');
const notAwarded = new PrizeViewModel(createPrizeDto({ awarded: false, awardedAt: undefined }));
expect(notAwarded.formattedAwardedAt).toBe('Not awarded');
});
});

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { ProtestDriverViewModel } from './ProtestDriverViewModel';
import type { DriverSummaryDTO } from '../types/generated/LeagueAdminProtestsDTO';
const createDriverSummary = (overrides: Partial<DriverSummaryDTO> = {}): DriverSummaryDTO => ({
id: 'driver-1',
name: 'Test Driver',
...overrides,
});
describe('ProtestDriverViewModel', () => {
it('maps id and name from DriverSummaryDTO', () => {
const dto = createDriverSummary({
id: 'driver-123',
name: 'Jane Doe',
});
const viewModel = new ProtestDriverViewModel(dto);
expect(viewModel.id).toBe('driver-123');
expect(viewModel.name).toBe('Jane Doe');
});
it('exposes underlying DTO fields as-is', () => {
const dto = createDriverSummary();
const viewModel = new ProtestDriverViewModel(dto);
expect(viewModel.id).toBe(dto.id);
expect(viewModel.name).toBe(dto.name);
});
});

View File

@@ -2,113 +2,52 @@ import { describe, it, expect } from 'vitest';
import { ProtestViewModel } from './ProtestViewModel';
import type { ProtestDTO } from '../types/generated/ProtestDTO';
const createProtestDto = (overrides: Partial<ProtestDTO> = {}): ProtestDTO => ({
id: 'protest-123',
raceId: 'race-456',
protestingDriverId: 'driver-111',
accusedDriverId: 'driver-222',
description: 'Unsafe driving in turn 3',
submittedAt: '2023-01-15T10:30:00Z',
...overrides,
});
describe('ProtestViewModel', () => {
it('should create instance with all properties', () => {
const dto: ProtestDTO = {
id: 'protest-123',
raceId: 'race-456',
complainantId: 'driver-111',
defendantId: 'driver-222',
description: 'Unsafe driving in turn 3',
status: 'pending',
createdAt: '2023-01-15T10:30:00Z',
};
it('maps core fields from ProtestDTO', () => {
const dto: ProtestDTO = createProtestDto();
const viewModel = new ProtestViewModel(dto);
expect(viewModel.id).toBe('protest-123');
expect(viewModel.raceId).toBe('race-456');
expect(viewModel.complainantId).toBe('driver-111');
expect(viewModel.defendantId).toBe('driver-222');
expect(viewModel.protestingDriverId).toBe('driver-111');
expect(viewModel.accusedDriverId).toBe('driver-222');
expect(viewModel.description).toBe('Unsafe driving in turn 3');
expect(viewModel.submittedAt).toBe('2023-01-15T10:30:00Z');
});
it('defaults status and review fields for new protests', () => {
const viewModel = new ProtestViewModel(createProtestDto());
expect(viewModel.status).toBe('pending');
expect(viewModel.createdAt).toBe('2023-01-15T10:30:00Z');
expect(viewModel.reviewedAt).toBeUndefined();
expect(viewModel.decisionNotes).toBeUndefined();
});
it('should format createdAt as locale string', () => {
const dto: ProtestDTO = {
id: 'protest-123',
raceId: 'race-456',
complainantId: 'driver-111',
defendantId: 'driver-222',
description: 'Test',
status: 'pending',
createdAt: '2023-01-15T10:30:00Z',
};
it('formats submittedAt as a locale string', () => {
const viewModel = new ProtestViewModel(createProtestDto({
submittedAt: '2023-01-15T10:30:00Z',
}));
const viewModel = new ProtestViewModel(dto);
const formatted = viewModel.formattedCreatedAt;
const formatted = viewModel.formattedSubmittedAt;
expect(formatted).toContain('2023');
expect(formatted).toContain('1/15');
expect(typeof formatted).toBe('string');
expect(formatted.length).toBeGreaterThan(0);
});
it('should capitalize status for display', () => {
const statuses = ['pending', 'approved', 'rejected', 'reviewing'];
statuses.forEach(status => {
const dto: ProtestDTO = {
id: 'protest-123',
raceId: 'race-456',
complainantId: 'driver-111',
defendantId: 'driver-222',
description: 'Test',
status,
createdAt: '2023-01-15T10:30:00Z',
};
const viewModel = new ProtestViewModel(dto);
const expected = status.charAt(0).toUpperCase() + status.slice(1);
expect(viewModel.statusDisplay).toBe(expected);
});
});
it('should handle already capitalized status', () => {
const dto: ProtestDTO = {
id: 'protest-123',
raceId: 'race-456',
complainantId: 'driver-111',
defendantId: 'driver-222',
description: 'Test',
status: 'Pending',
createdAt: '2023-01-15T10:30:00Z',
};
const viewModel = new ProtestViewModel(dto);
it('exposes a fixed status display label for pending protests', () => {
const viewModel = new ProtestViewModel(createProtestDto());
expect(viewModel.statusDisplay).toBe('Pending');
});
it('should handle single character status', () => {
const dto: ProtestDTO = {
id: 'protest-123',
raceId: 'race-456',
complainantId: 'driver-111',
defendantId: 'driver-222',
description: 'Test',
status: 'p',
createdAt: '2023-01-15T10:30:00Z',
};
const viewModel = new ProtestViewModel(dto);
expect(viewModel.statusDisplay).toBe('P');
});
it('should handle empty status', () => {
const dto: ProtestDTO = {
id: 'protest-123',
raceId: 'race-456',
complainantId: 'driver-111',
defendantId: 'driver-222',
description: 'Test',
status: '',
createdAt: '2023-01-15T10:30:00Z',
};
const viewModel = new ProtestViewModel(dto);
expect(viewModel.statusDisplay).toBe('');
});
});

View File

@@ -0,0 +1,130 @@
import { describe, it, expect } from 'vitest';
import { RaceResultsDetailViewModel } from './RaceResultsDetailViewModel';
import { RaceResultViewModel } from './RaceResultViewModel';
import type { RaceResultsDetailDTO } from '../types/generated/RaceResultsDetailDTO';
import type { RaceResultDTO } from '../types/generated/RaceResultDTO';
const createResult = (overrides: Partial<RaceResultDTO> = {}): RaceResultDTO => ({
driverId: 'driver-1',
driverName: 'Driver One',
avatarUrl: 'https://example.com/avatar.jpg',
position: 2,
startPosition: 3,
incidents: 1,
fastestLap: 90.5,
positionChange: 1,
isPodium: true,
isClean: true,
...overrides,
});
const createDto = (overrides: Partial<RaceResultsDetailDTO & { results?: RaceResultDTO[] }> = {}): RaceResultsDetailDTO & { results?: RaceResultDTO[] } => ({
raceId: 'race-1',
track: 'Spa',
...overrides,
});
describe('RaceResultsDetailViewModel', () => {
it('maps basic fields and wraps results into view models', () => {
const dto = createDto({
results: [createResult({ driverId: 'driver-1' }), createResult({ driverId: 'driver-2' })],
});
const viewModel = new RaceResultsDetailViewModel(dto, 'driver-1');
expect(viewModel.raceId).toBe('race-1');
expect(viewModel.track).toBe('Spa');
expect(viewModel.results).toHaveLength(2);
expect(viewModel.results[0]).toBeInstanceOf(RaceResultViewModel);
});
it('sorts results by position', () => {
const dto = createDto({
results: [
createResult({ driverId: 'driver-1', position: 3 }),
createResult({ driverId: 'driver-2', position: 1 }),
createResult({ driverId: 'driver-3', position: 2 }),
],
});
const viewModel = new RaceResultsDetailViewModel(dto, 'driver-1');
const byPosition = viewModel.resultsByPosition;
expect(byPosition.map(r => r.position)).toEqual([1, 2, 3]);
});
it('sorts results by fastest lap', () => {
const dto = createDto({
results: [
createResult({ driverId: 'driver-1', fastestLap: 95 }),
createResult({ driverId: 'driver-2', fastestLap: 90 }),
createResult({ driverId: 'driver-3', fastestLap: 100 }),
],
});
const viewModel = new RaceResultsDetailViewModel(dto, 'driver-1');
const byLap = viewModel.resultsByFastestLap;
expect(byLap.map(r => r.fastestLap)).toEqual([90, 95, 100]);
});
it('filters clean drivers', () => {
const dto = createDto({
results: [
createResult({ driverId: 'driver-1', isClean: true }),
createResult({ driverId: 'driver-2', isClean: false }),
],
});
const viewModel = new RaceResultsDetailViewModel(dto, 'driver-1');
const clean = viewModel.cleanDrivers;
expect(clean).toHaveLength(1);
expect(clean[0].driverId).toBe('driver-1');
});
it('finds current user result by driver id', () => {
const dto = createDto({
results: [
createResult({ driverId: 'driver-1' }),
createResult({ driverId: 'driver-2' }),
],
});
const viewModel = new RaceResultsDetailViewModel(dto, 'driver-2');
expect(viewModel.currentUserResult?.driverId).toBe('driver-2');
});
it('computes stats for total drivers, clean rate and average incidents', () => {
const dto = createDto({
results: [
createResult({ driverId: 'driver-1', isClean: true, incidents: 0 }),
createResult({ driverId: 'driver-2', isClean: false, incidents: 4 }),
],
});
const viewModel = new RaceResultsDetailViewModel(dto, 'driver-1');
const stats = viewModel.stats;
expect(stats.totalDrivers).toBe(2);
expect(stats.cleanRate).toBeCloseTo(50);
expect(stats.averageIncidents).toBeCloseTo(2);
});
it('returns zeroed stats when there are no results', () => {
const dto = createDto({ results: [] });
const viewModel = new RaceResultsDetailViewModel(dto, 'driver-1');
const stats = viewModel.stats;
expect(stats.totalDrivers).toBe(0);
expect(stats.cleanRate).toBe(0);
expect(stats.averageIncidents).toBe(0);
});
});

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { RaceStatsViewModel } from './RaceStatsViewModel';
import type { RaceStatsDTO } from '../types/generated';
const createDto = (overrides: Partial<RaceStatsDTO> = {}): RaceStatsDTO => ({
totalRaces: 1234,
...overrides,
});
describe('RaceStatsViewModel', () => {
it('maps totalRaces from DTO', () => {
const dto = createDto({ totalRaces: 42 });
const viewModel = new RaceStatsViewModel(dto);
expect(viewModel.totalRaces).toBe(42);
});
it('formats totalRaces with locale separators', () => {
const dto = createDto({ totalRaces: 12345 });
const viewModel = new RaceStatsViewModel(dto);
const formatted = viewModel.formattedTotalRaces;
expect(formatted).toBe((12345).toLocaleString());
});
});

View File

@@ -0,0 +1,140 @@
import { describe, it, expect } from 'vitest';
import { RaceStewardingViewModel } from './RaceStewardingViewModel';
const createRaceStewardingDto = () => ({
raceDetail: {
race: {
id: 'race-1',
track: 'Spa-Francorchamps',
scheduledAt: '2024-01-01T20:00:00Z',
status: 'completed',
},
league: {
id: 'league-1',
name: 'Test League',
},
},
protests: {
protests: [
{
id: 'p1',
protestingDriverId: 'd1',
accusedDriverId: 'd2',
incident: { lap: 1, description: 'Turn 1 divebomb' },
filedAt: '2024-01-01T21:00:00Z',
status: 'pending',
},
{
id: 'p2',
protestingDriverId: 'd3',
accusedDriverId: 'd4',
incident: { lap: 5, description: 'Blocking' },
filedAt: '2024-01-01T21:05:00Z',
status: 'under_review',
},
{
id: 'p3',
protestingDriverId: 'd5',
accusedDriverId: 'd6',
incident: { lap: 10, description: 'Contact' },
filedAt: '2024-01-01T21:10:00Z',
status: 'upheld',
},
{
id: 'p4',
protestingDriverId: 'd7',
accusedDriverId: 'd8',
incident: { lap: 12, description: 'Off-track overtake' },
filedAt: '2024-01-01T21:15:00Z',
status: 'dismissed',
},
{
id: 'p5',
protestingDriverId: 'd9',
accusedDriverId: 'd10',
incident: { lap: 15, description: 'Withdrawn protest' },
filedAt: '2024-01-01T21:20:00Z',
status: 'withdrawn',
},
],
driverMap: {
d1: { id: 'd1', name: 'Driver 1' },
d2: { id: 'd2', name: 'Driver 2' },
},
},
penalties: {
penalties: [
{
id: 'pen1',
driverId: 'd2',
type: 'time',
value: 5,
reason: 'Avoidable contact',
},
{
id: 'pen2',
driverId: 'd3',
type: 'points',
value: 2,
reason: 'Reckless driving',
},
],
driverMap: {
d3: { id: 'd3', name: 'Driver 3' },
d4: { id: 'd4', name: 'Driver 4' },
},
},
});
describe('RaceStewardingViewModel', () => {
it('maps core race, league, protests, penalties and driver map', () => {
const dto = createRaceStewardingDto();
const viewModel = new RaceStewardingViewModel(dto as any);
expect(viewModel.race).toEqual(dto.raceDetail.race);
expect(viewModel.league).toEqual(dto.raceDetail.league);
expect(viewModel.protests).toEqual(dto.protests.protests);
expect(viewModel.penalties).toEqual(dto.penalties.penalties);
expect(viewModel.driverMap).toEqual({
...dto.protests.driverMap,
...dto.penalties.driverMap,
});
});
it('derives pending and resolved protest buckets and counts', () => {
const dto = createRaceStewardingDto();
const viewModel = new RaceStewardingViewModel(dto as any);
const pendingIds = viewModel.pendingProtests.map(p => p.id);
const resolvedIds = viewModel.resolvedProtests.map(p => p.id);
expect(pendingIds.sort()).toEqual(['p1', 'p2']);
expect(resolvedIds.sort()).toEqual(['p3', 'p4', 'p5']);
expect(viewModel.pendingCount).toBe(2);
expect(viewModel.resolvedCount).toBe(3);
});
it('derives penalties count from penalties list', () => {
const dto = createRaceStewardingDto();
const viewModel = new RaceStewardingViewModel(dto as any);
expect(viewModel.penaltiesCount).toBe(2);
});
it('handles empty protests and penalties gracefully', () => {
const dto = createRaceStewardingDto();
dto.protests.protests = [];
dto.penalties.penalties = [];
const viewModel = new RaceStewardingViewModel(dto as any);
expect(viewModel.pendingProtests).toEqual([]);
expect(viewModel.resolvedProtests).toEqual([]);
expect(viewModel.pendingCount).toBe(0);
expect(viewModel.resolvedCount).toBe(0);
expect(viewModel.penaltiesCount).toBe(0);
});
});

View File

@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { RaceViewModel } from './RaceViewModel';
import type { RaceDTO } from '../types/generated/RaceDTO';
const createRaceDto = (overrides: Partial<RaceDTO> = {}): RaceDTO => ({
id: 'race-1',
name: 'Season Opener',
date: '2025-01-01T20:00:00Z',
...overrides,
});
describe('RaceViewModel', () => {
it('maps basic DTO fields', () => {
const dto = createRaceDto({ id: 'race-123', name: 'Test Race', date: '2025-02-01T18:30:00Z' });
const viewModel = new RaceViewModel(dto);
expect(viewModel.id).toBe('race-123');
expect(viewModel.name).toBe('Test Race');
expect(viewModel.date).toBe('2025-02-01T18:30:00Z');
});
it('exposes optional status, registeredCount and strengthOfField from constructor arguments', () => {
const dto = createRaceDto();
const viewModel = new RaceViewModel(dto, 'upcoming', 25, 3000);
expect(viewModel.status).toBe('upcoming');
expect(viewModel.registeredCount).toBe(25);
expect(viewModel.strengthOfField).toBe(3000);
});
it('formats date to locale string', () => {
const dto = createRaceDto({ date: '2025-01-01T20:00:00Z' });
const viewModel = new RaceViewModel(dto);
const formatted = viewModel.formattedDate;
expect(formatted).toContain('2025');
});
});

View File

@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { RaceWithSOFViewModel } from './RaceWithSOFViewModel';
import type { RaceWithSOFDTO } from '../types/generated/RaceWithSOFDTO';
const createDto = (overrides: Partial<RaceWithSOFDTO> = {}): RaceWithSOFDTO => ({
id: 'race-sof-1',
track: 'Spa',
...overrides,
});
describe('RaceWithSOFViewModel', () => {
it('maps DTO fields', () => {
const dto = createDto({ id: 'race-sof-123', track: 'Nürburgring' });
const viewModel = new RaceWithSOFViewModel(dto);
expect(viewModel.id).toBe('race-sof-123');
expect(viewModel.track).toBe('Nürburgring');
});
});

View File

@@ -1,212 +1,132 @@
import { describe, it, expect } from 'vitest';
import { RaceCardViewModel, RacesPageViewModel } from './RacesPageViewModel';
import { RacesPageViewModel } from './RacesPageViewModel';
import { RaceListItemViewModel } from './RaceListItemViewModel';
describe('RaceCardViewModel', () => {
it('should create instance with all properties', () => {
const dto = {
id: 'race-123',
title: 'Season Finale',
scheduledTime: '2023-12-31T20:00:00Z',
status: 'upcoming',
};
describe('RaceListItemViewModel', () => {
const baseDto = {
id: 'race-123',
track: 'Spa-Francorchamps',
car: 'GT3',
scheduledAt: '2025-01-01T20:00:00Z',
status: 'scheduled',
leagueId: 'league-1',
leagueName: 'Test League',
strengthOfField: 2500,
isUpcoming: true,
isLive: false,
isPast: false,
};
const viewModel = new RaceCardViewModel(dto);
it('maps DTO fields to properties', () => {
const viewModel = new RaceListItemViewModel(baseDto);
expect(viewModel.id).toBe('race-123');
expect(viewModel.title).toBe('Season Finale');
expect(viewModel.scheduledTime).toBe('2023-12-31T20:00:00Z');
expect(viewModel.status).toBe('upcoming');
expect(viewModel.track).toBe('Spa-Francorchamps');
expect(viewModel.car).toBe('GT3');
expect(viewModel.scheduledAt).toBe('2025-01-01T20:00:00Z');
expect(viewModel.status).toBe('scheduled');
expect(viewModel.leagueId).toBe('league-1');
expect(viewModel.leagueName).toBe('Test League');
expect(viewModel.strengthOfField).toBe(2500);
expect(viewModel.isUpcoming).toBe(true);
expect(viewModel.isLive).toBe(false);
expect(viewModel.isPast).toBe(false);
});
it('should format scheduled time as locale string', () => {
const dto = {
id: 'race-123',
title: 'Test Race',
scheduledTime: '2023-12-31T20:00:00Z',
status: 'upcoming',
};
it('formats scheduled time as locale string', () => {
const viewModel = new RaceListItemViewModel(baseDto);
const viewModel = new RaceCardViewModel(dto);
const formatted = viewModel.formattedScheduledTime;
expect(formatted).toContain('2023');
expect(formatted).toContain('12/31');
expect(formatted).toContain('2025');
});
it('should handle different race statuses', () => {
const statuses = ['upcoming', 'live', 'finished', 'cancelled'];
it('computes status badge variants', () => {
const scheduled = new RaceListItemViewModel({ ...baseDto, status: 'scheduled' });
const running = new RaceListItemViewModel({ ...baseDto, status: 'running' });
const completed = new RaceListItemViewModel({ ...baseDto, status: 'completed' });
const cancelled = new RaceListItemViewModel({ ...baseDto, status: 'cancelled' });
const other = new RaceListItemViewModel({ ...baseDto, status: 'unknown' });
statuses.forEach(status => {
const dto = {
id: 'race-123',
title: 'Test Race',
scheduledTime: '2023-12-31T20:00:00Z',
status,
};
const viewModel = new RaceCardViewModel(dto);
expect(viewModel.status).toBe(status);
});
expect(scheduled.statusBadgeVariant).toBe('info');
expect(running.statusBadgeVariant).toBe('success');
expect(completed.statusBadgeVariant).toBe('secondary');
expect(cancelled.statusBadgeVariant).toBe('danger');
expect(other.statusBadgeVariant).toBe('default');
});
});
describe('RacesPageViewModel', () => {
it('should create instance with upcoming and completed races', () => {
const dto = {
upcomingRaces: [
{
id: 'race-1',
title: 'Race 1',
scheduledTime: '2023-12-31T20:00:00Z',
status: 'upcoming',
},
{
id: 'race-2',
title: 'Race 2',
scheduledTime: '2024-01-01T20:00:00Z',
status: 'upcoming',
},
],
completedRaces: [
{
id: 'race-3',
title: 'Race 3',
scheduledTime: '2023-12-20T20:00:00Z',
status: 'finished',
},
],
totalCount: 3,
};
const createDto = () => ({
races: [
{
id: 'race-1',
track: 'Spa',
car: 'GT3',
scheduledAt: '2025-01-01T20:00:00Z',
status: 'scheduled',
leagueId: 'league-1',
leagueName: 'League 1',
strengthOfField: 2500,
isUpcoming: true,
isLive: false,
isPast: false,
},
{
id: 'race-2',
track: 'Nürburgring',
car: 'GT4',
scheduledAt: '2025-01-02T20:00:00Z',
status: 'running',
leagueId: 'league-2',
leagueName: 'League 2',
strengthOfField: null,
isUpcoming: true,
isLive: true,
isPast: false,
},
{
id: 'race-3',
track: 'Daytona',
car: 'LMP2',
scheduledAt: '2024-12-31T20:00:00Z',
status: 'completed',
leagueId: 'league-3',
leagueName: 'League 3',
strengthOfField: 2000,
isUpcoming: false,
isLive: false,
isPast: true,
},
],
});
const viewModel = new RacesPageViewModel(dto);
it('wraps races DTOs in RaceListItemViewModel instances', () => {
const viewModel = new RacesPageViewModel(createDto());
expect(viewModel.races).toHaveLength(3);
expect(viewModel.races[0]).toBeInstanceOf(RaceListItemViewModel);
});
it('computes total count from races length', () => {
const viewModel = new RacesPageViewModel(createDto());
expect(viewModel.totalCount).toBe(3);
});
it('filters upcoming, live and past races', () => {
const viewModel = new RacesPageViewModel(createDto());
expect(viewModel.upcomingRaces).toHaveLength(2);
expect(viewModel.liveRaces).toHaveLength(1);
expect(viewModel.pastRaces).toHaveLength(1);
});
it('filters scheduled, running and completed races by status', () => {
const viewModel = new RacesPageViewModel(createDto());
expect(viewModel.scheduledRaces).toHaveLength(1);
expect(viewModel.runningRaces).toHaveLength(1);
expect(viewModel.completedRaces).toHaveLength(1);
expect(viewModel.totalCount).toBe(3);
});
it('should convert DTOs to view models', () => {
const dto = {
upcomingRaces: [
{
id: 'race-1',
title: 'Race 1',
scheduledTime: '2023-12-31T20:00:00Z',
status: 'upcoming',
},
],
completedRaces: [],
totalCount: 1,
};
const viewModel = new RacesPageViewModel(dto);
expect(viewModel.upcomingRaces[0]).toBeInstanceOf(RaceCardViewModel);
expect(viewModel.upcomingRaces[0].id).toBe('race-1');
});
it('should return correct upcoming count', () => {
const dto = {
upcomingRaces: [
{
id: 'race-1',
title: 'Race 1',
scheduledTime: '2023-12-31T20:00:00Z',
status: 'upcoming',
},
{
id: 'race-2',
title: 'Race 2',
scheduledTime: '2024-01-01T20:00:00Z',
status: 'upcoming',
},
{
id: 'race-3',
title: 'Race 3',
scheduledTime: '2024-01-02T20:00:00Z',
status: 'upcoming',
},
],
completedRaces: [],
totalCount: 3,
};
const viewModel = new RacesPageViewModel(dto);
expect(viewModel.upcomingCount).toBe(3);
});
it('should return correct completed count', () => {
const dto = {
upcomingRaces: [],
completedRaces: [
{
id: 'race-1',
title: 'Race 1',
scheduledTime: '2023-12-20T20:00:00Z',
status: 'finished',
},
{
id: 'race-2',
title: 'Race 2',
scheduledTime: '2023-12-21T20:00:00Z',
status: 'finished',
},
],
totalCount: 2,
};
const viewModel = new RacesPageViewModel(dto);
expect(viewModel.completedCount).toBe(2);
});
it('should handle empty race lists', () => {
const dto = {
upcomingRaces: [],
completedRaces: [],
totalCount: 0,
};
const viewModel = new RacesPageViewModel(dto);
expect(viewModel.upcomingCount).toBe(0);
expect(viewModel.completedCount).toBe(0);
expect(viewModel.totalCount).toBe(0);
});
it('should handle mixed race lists', () => {
const dto = {
upcomingRaces: [
{
id: 'race-1',
title: 'Upcoming',
scheduledTime: '2024-01-01T20:00:00Z',
status: 'upcoming',
},
],
completedRaces: [
{
id: 'race-2',
title: 'Completed 1',
scheduledTime: '2023-12-20T20:00:00Z',
status: 'finished',
},
{
id: 'race-3',
title: 'Completed 2',
scheduledTime: '2023-12-21T20:00:00Z',
status: 'finished',
},
],
totalCount: 3,
};
const viewModel = new RacesPageViewModel(dto);
expect(viewModel.upcomingCount).toBe(1);
expect(viewModel.completedCount).toBe(2);
expect(viewModel.totalCount).toBe(3);
});
});

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { RecordEngagementInputViewModel } from './RecordEngagementInputViewModel';
describe('RecordEngagementInputViewModel', () => {
it('maps basic fields from input data', () => {
const vm = new RecordEngagementInputViewModel({
eventType: 'button_click',
userId: 'user-1',
metadata: { key: 'value' },
});
expect(vm.eventType).toBe('button_click');
expect(vm.userId).toBe('user-1');
expect(vm.metadata).toEqual({ key: 'value' });
});
it('derives displayEventType from snake_case eventType', () => {
const vm = new RecordEngagementInputViewModel({
eventType: 'page_view',
});
expect(vm.displayEventType).toBe('Page View');
});
it('detects presence of metadata and counts keys', () => {
const withMetadata = new RecordEngagementInputViewModel({
eventType: 'test',
metadata: { a: 1, b: 2 },
});
const withoutMetadata = new RecordEngagementInputViewModel({
eventType: 'test',
});
const emptyMetadata = new RecordEngagementInputViewModel({
eventType: 'test',
metadata: {},
});
expect(withMetadata.hasMetadata).toBe(true);
expect(withMetadata.metadataKeysCount).toBe(2);
expect(withoutMetadata.hasMetadata).toBe(false);
expect(withoutMetadata.metadataKeysCount).toBe(0);
expect(emptyMetadata.hasMetadata).toBe(false);
expect(emptyMetadata.metadataKeysCount).toBe(0);
});
});

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { RecordEngagementOutputViewModel } from './RecordEngagementOutputViewModel';
describe('RecordEngagementOutputViewModel', () => {
it('maps DTO fields into view model', () => {
const dto = { eventId: 'evt-123', engagementWeight: 1.2345 } as any;
const vm = new RecordEngagementOutputViewModel(dto);
expect(vm.eventId).toBe('evt-123');
expect(vm.engagementWeight).toBe(1.2345);
});
it('formats displayEventId with prefix', () => {
const vm = new RecordEngagementOutputViewModel({ eventId: 'evt-999', engagementWeight: 0.5 } as any);
expect(vm.displayEventId).toBe('Event: evt-999');
});
it('formats engagement weight to two decimals', () => {
const vm = new RecordEngagementOutputViewModel({ eventId: 'evt', engagementWeight: 1.2 } as any);
expect(vm.displayEngagementWeight).toBe('1.20');
});
it('flags high engagement above threshold', () => {
const low = new RecordEngagementOutputViewModel({ eventId: 'evt', engagementWeight: 0.5 } as any);
const high = new RecordEngagementOutputViewModel({ eventId: 'evt', engagementWeight: 1.5 } as any);
expect(low.isHighEngagement).toBe(false);
expect(high.isHighEngagement).toBe(true);
});
});

View File

@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { RecordPageViewInputViewModel } from './RecordPageViewInputViewModel';
describe('RecordPageViewInputViewModel', () => {
it('maps basic fields from input data', () => {
const vm = new RecordPageViewInputViewModel({ path: '/home', userId: 'user-1' });
expect(vm.path).toBe('/home');
expect(vm.userId).toBe('user-1');
});
it('normalizes displayPath to always start with a slash', () => {
const withLeadingSlash = new RecordPageViewInputViewModel({ path: '/dashboard' });
const withoutLeadingSlash = new RecordPageViewInputViewModel({ path: 'settings' });
expect(withLeadingSlash.displayPath).toBe('/dashboard');
expect(withoutLeadingSlash.displayPath).toBe('/settings');
});
it('detects when user context is present', () => {
const withUser = new RecordPageViewInputViewModel({ path: '/', userId: 'user-1' });
const withoutUser = new RecordPageViewInputViewModel({ path: '/' });
const emptyUserId = new RecordPageViewInputViewModel({ path: '/', userId: '' });
expect(withUser.hasUserContext).toBe(true);
expect(withoutUser.hasUserContext).toBe(false);
expect(emptyUserId.hasUserContext).toBe(false);
});
});

View File

@@ -0,0 +1,18 @@
import { describe, it, expect } from 'vitest';
import { RecordPageViewOutputViewModel } from './RecordPageViewOutputViewModel';
describe('RecordPageViewOutputViewModel', () => {
it('maps DTO fields into view model', () => {
const dto = { pageViewId: 'pv-123' } as any;
const vm = new RecordPageViewOutputViewModel(dto);
expect(vm.pageViewId).toBe('pv-123');
});
it('formats displayPageViewId with prefix', () => {
const vm = new RecordPageViewOutputViewModel({ pageViewId: 'pv-999' } as any);
expect(vm.displayPageViewId).toBe('Page View: pv-999');
});
});

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { RemoveMemberViewModel } from './RemoveMemberViewModel';
import type { RemoveLeagueMemberOutputDTO } from '../types/generated/RemoveLeagueMemberOutputDTO';
const createRemoveMemberDto = (overrides: Partial<RemoveLeagueMemberOutputDTO> = {}): RemoveLeagueMemberOutputDTO => ({
success: true,
...overrides,
});
describe('RemoveMemberViewModel', () => {
it('maps success flag from DTO', () => {
const dto = createRemoveMemberDto({ success: true });
const vm = new RemoveMemberViewModel(dto);
expect(vm.success).toBe(true);
});
it('provides success message when operation succeeds', () => {
const dto = createRemoveMemberDto({ success: true });
const vm = new RemoveMemberViewModel(dto);
expect(vm.successMessage).toBe('Member removed successfully!');
});
it('provides failure message when operation fails', () => {
const dto = createRemoveMemberDto({ success: false });
const vm = new RemoveMemberViewModel(dto);
expect(vm.successMessage).toBe('Failed to remove member.');
});
});

View File

@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { RenewalAlertViewModel } from './RenewalAlertViewModel';
describe('RenewalAlertViewModel', () => {
it('maps core fields and derives formatted price and date', () => {
const vm = new RenewalAlertViewModel({
id: 'ren-1',
name: 'League Sponsorship',
type: 'league',
renewDate: '2024-01-15',
price: 100,
});
expect(vm.id).toBe('ren-1');
expect(vm.name).toBe('League Sponsorship');
expect(vm.type).toBe('league');
expect(vm.formattedPrice).toBe('$100');
expect(typeof vm.formattedRenewDate).toBe('string');
});
it('maps type to icon name', () => {
const league = new RenewalAlertViewModel({ id: '1', name: 'A', type: 'league', renewDate: '2024-01-01', price: 0 });
const team = new RenewalAlertViewModel({ id: '2', name: 'B', type: 'team', renewDate: '2024-01-01', price: 0 });
const driver = new RenewalAlertViewModel({ id: '3', name: 'C', type: 'driver', renewDate: '2024-01-01', price: 0 });
const race = new RenewalAlertViewModel({ id: '4', name: 'D', type: 'race', renewDate: '2024-01-01', price: 0 });
const platform = new RenewalAlertViewModel({ id: '5', name: 'E', type: 'platform', renewDate: '2024-01-01', price: 0 });
expect(league.typeIcon).toBe('Trophy');
expect(team.typeIcon).toBe('Users');
expect(driver.typeIcon).toBe('Car');
expect(race.typeIcon).toBe('Flag');
expect(platform.typeIcon).toBe('Megaphone');
});
it('computes daysUntilRenewal and urgency flag based on current date', () => {
const now = new Date();
const soon = new Date(now.getTime() + 10 * 24 * 60 * 60 * 1000);
const later = new Date(now.getTime() + 40 * 24 * 60 * 60 * 1000);
const soonAlert = new RenewalAlertViewModel({
id: 'soon',
name: 'Soon',
type: 'league',
renewDate: soon.toISOString(),
price: 0,
});
const laterAlert = new RenewalAlertViewModel({
id: 'later',
name: 'Later',
type: 'league',
renewDate: later.toISOString(),
price: 0,
});
expect(soonAlert.daysUntilRenewal).toBeGreaterThan(0);
expect(soonAlert.isUrgent).toBe(true);
expect(laterAlert.daysUntilRenewal).toBeGreaterThan(30);
expect(laterAlert.isUrgent).toBe(false);
});
});

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { RequestAvatarGenerationViewModel } from './RequestAvatarGenerationViewModel';
describe('RequestAvatarGenerationViewModel', () => {
it('creates instance with successful generation and avatarUrl', () => {
const dto = {
success: true,
avatarUrl: 'https://example.com/generated-avatar.jpg',
};
const viewModel = new RequestAvatarGenerationViewModel(dto);
expect(viewModel.success).toBe(true);
expect(viewModel.avatarUrl).toBe('https://example.com/generated-avatar.jpg');
expect(viewModel.error).toBeUndefined();
});
it('creates instance with failed generation and error', () => {
const dto = {
success: false,
error: 'Generation failed',
};
const viewModel = new RequestAvatarGenerationViewModel(dto);
expect(viewModel.success).toBe(false);
expect(viewModel.avatarUrl).toBeUndefined();
expect(viewModel.error).toBe('Generation failed');
});
it('isSuccessful reflects success flag', () => {
const successVm = new RequestAvatarGenerationViewModel({ success: true });
const failureVm = new RequestAvatarGenerationViewModel({ success: false });
expect(successVm.isSuccessful).toBe(true);
expect(failureVm.isSuccessful).toBe(false);
});
it('hasError reflects presence of error message', () => {
const noErrorVm = new RequestAvatarGenerationViewModel({ success: true });
const errorVm = new RequestAvatarGenerationViewModel({ success: false, error: 'Something went wrong' });
expect(noErrorVm.hasError).toBe(false);
expect(errorVm.hasError).toBe(true);
});
it('treats empty error string as no error', () => {
const viewModel = new RequestAvatarGenerationViewModel({ success: false, error: '' });
expect(viewModel.hasError).toBe(false);
});
});

View File

@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { SessionViewModel } from './SessionViewModel';
import type { AuthenticatedUserDTO } from '../types/generated/AuthenticatedUserDTO';
describe('SessionViewModel', () => {
const createDto = (overrides?: Partial<AuthenticatedUserDTO>): AuthenticatedUserDTO => ({
userId: 'user-1',
email: 'user@example.com',
displayName: 'Test User',
...overrides,
});
it('maps basic user identity fields from the DTO', () => {
const dto = createDto();
const viewModel = new SessionViewModel(dto);
expect(viewModel.userId).toBe('user-1');
expect(viewModel.email).toBe('user@example.com');
expect(viewModel.displayName).toBe('Test User');
});
it('provides a greeting based on the display name', () => {
const dto = createDto({ displayName: 'Roo Racer' });
const viewModel = new SessionViewModel(dto);
expect(viewModel.greeting).toBe('Hello, Roo Racer!');
});
it('derives avatar initials from the display name', () => {
const dto = createDto({ displayName: 'Roo Racer' });
const viewModel = new SessionViewModel(dto);
expect(viewModel.avatarInitials).toBe('RR');
});
it('falls back to the email first letter when display name is empty', () => {
const dto = createDto({ displayName: '' });
const viewModel = new SessionViewModel(dto);
expect(viewModel.avatarInitials).toBe('U');
});
it('indicates when a driver profile is present via hasDriverProfile', () => {
const dto = createDto();
const withoutDriver = new SessionViewModel(dto);
const withDriver = new SessionViewModel(dto);
withDriver.driverId = 'driver-1';
expect(withoutDriver.hasDriverProfile).toBe(false);
expect(withDriver.hasDriverProfile).toBe(true);
});
it('returns the correct authStatusDisplay based on isAuthenticated flag', () => {
const dto = createDto();
const authenticated = new SessionViewModel(dto);
const unauthenticated = new SessionViewModel(dto);
unauthenticated.isAuthenticated = false;
expect(authenticated.authStatusDisplay).toBe('Logged In');
expect(unauthenticated.authStatusDisplay).toBe('Logged Out');
});
});

View File

@@ -0,0 +1,165 @@
import { describe, it, expect } from 'vitest';
import { SponsorDashboardViewModel } from './SponsorDashboardViewModel';
import { SponsorshipViewModel } from './SponsorshipViewModel';
import { ActivityItemViewModel } from './ActivityItemViewModel';
import { RenewalAlertViewModel } from './RenewalAlertViewModel';
function makeSponsorship(overrides: Partial<any> = {}) {
return {
id: 's-1',
type: 'leagues',
entityId: 'league-1',
entityName: 'Pro League',
status: 'active',
startDate: '2025-01-01',
endDate: '2025-12-31',
price: 5_000,
impressions: 50_000,
...overrides,
};
}
describe('SponsorDashboardViewModel', () => {
const baseDto = {
sponsorId: 'sp-1',
sponsorName: 'Acme Corp',
metrics: { totalSpend: 10_000, totalImpressions: 100_000 },
sponsorships: {
leagues: [makeSponsorship()],
teams: [makeSponsorship({ id: 's-2', type: 'teams', price: 2_000, impressions: 20_000 })],
drivers: [],
races: [],
platform: [],
},
recentActivity: [
{ id: 'a-1', type: 'impression', timestamp: '2025-01-01T00:00:00Z', metadata: {} },
],
upcomingRenewals: [
{ id: 'r-1', sponsorshipId: 's-1', leagueName: 'Pro League', daysUntilRenewal: 10 },
],
} as any;
it('maps nested DTOs into view models', () => {
const vm = new SponsorDashboardViewModel(baseDto);
expect(vm.sponsorId).toBe(baseDto.sponsorId);
expect(vm.sponsorName).toBe(baseDto.sponsorName);
expect(vm.metrics).toBe(baseDto.metrics);
expect(vm.sponsorships.leagues[0]).toBeInstanceOf(SponsorshipViewModel);
expect(vm.sponsorships.teams[0]).toBeInstanceOf(SponsorshipViewModel);
expect(vm.recentActivity[0]).toBeInstanceOf(ActivityItemViewModel);
expect(vm.upcomingRenewals[0]).toBeInstanceOf(RenewalAlertViewModel);
});
it('computes total, active counts and investment from sponsorship buckets', () => {
const vm = new SponsorDashboardViewModel(baseDto);
const all = [
...baseDto.sponsorships.leagues,
...baseDto.sponsorships.teams,
];
expect(vm.totalSponsorships).toBe(all.length);
expect(vm.activeSponsorships).toBe(all.filter(s => s.status === 'active').length);
const expectedInvestment = all
.filter(s => s.status === 'active')
.reduce((sum, s) => sum + s.price, 0);
expect(vm.totalInvestment).toBe(expectedInvestment);
});
it('aggregates total impressions across all sponsorships', () => {
const vm = new SponsorDashboardViewModel(baseDto);
const all = [
...baseDto.sponsorships.leagues,
...baseDto.sponsorships.teams,
];
const expectedImpressions = all.reduce((sum, s) => sum + s.impressions, 0);
expect(vm.totalImpressions).toBe(expectedImpressions);
});
it('derives formatted investment, active percentage, status text and CPM', () => {
const vm = new SponsorDashboardViewModel(baseDto);
expect(vm.formattedTotalInvestment).toBe(`$${vm.totalInvestment.toLocaleString()}`);
const expectedActivePercentage = Math.round((vm.activeSponsorships / vm.totalSponsorships) * 100);
expect(vm.activePercentage).toBe(expectedActivePercentage);
expect(vm.hasSponsorships).toBe(true);
// statusText variants
const noActive = new SponsorDashboardViewModel({
...baseDto,
sponsorships: {
leagues: [makeSponsorship({ status: 'expired' })],
teams: [],
drivers: [],
races: [],
platform: [],
},
} as any);
expect(noActive.statusText).toBe('No active sponsorships');
const allActive = new SponsorDashboardViewModel({
...baseDto,
sponsorships: {
leagues: [makeSponsorship()],
teams: [],
drivers: [],
races: [],
platform: [],
},
} as any);
expect(allActive.statusText).toBe('All sponsorships active');
// cost per thousand views
expect(vm.costPerThousandViews).toBe(`$${(vm.totalInvestment / vm.totalImpressions * 1000).toFixed(2)}`);
const zeroImpressions = new SponsorDashboardViewModel({
...baseDto,
sponsorships: {
leagues: [makeSponsorship({ impressions: 0 })],
teams: [],
drivers: [],
races: [],
platform: [],
},
} as any);
expect(zeroImpressions.costPerThousandViews).toBe('$0.00');
});
it('exposes category data per sponsorship bucket', () => {
const vm = new SponsorDashboardViewModel(baseDto);
expect(vm.categoryData.leagues.count).toBe(baseDto.sponsorships.leagues.length);
expect(vm.categoryData.leagues.impressions).toBe(
baseDto.sponsorships.leagues.reduce((sum: number, s: any) => sum + s.impressions, 0),
);
expect(vm.categoryData.teams.count).toBe(baseDto.sponsorships.teams.length);
expect(vm.categoryData.teams.impressions).toBe(
baseDto.sponsorships.teams.reduce((sum: number, s: any) => sum + s.impressions, 0),
);
});
it('handles missing sponsorship buckets gracefully', () => {
const vm = new SponsorDashboardViewModel({
...baseDto,
sponsorships: undefined,
recentActivity: undefined,
upcomingRenewals: undefined,
} as any);
expect(vm.totalSponsorships).toBe(0);
expect(vm.activeSponsorships).toBe(0);
expect(vm.totalInvestment).toBe(0);
expect(vm.totalImpressions).toBe(0);
expect(vm.activePercentage).toBe(0);
expect(vm.hasSponsorships).toBe(false);
expect(vm.costPerThousandViews).toBe('$0.00');
expect(vm.recentActivity).toEqual([]);
expect(vm.upcomingRenewals).toEqual([]);
});
});

View File

@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { SponsorSettingsViewModel, SponsorProfileViewModel, NotificationSettingsViewModel, PrivacySettingsViewModel } from './SponsorSettingsViewModel';
describe('SponsorSettingsViewModel', () => {
const profile = {
companyName: 'Acme Corp',
contactName: 'John Doe',
contactEmail: 'john@example.com',
contactPhone: '+1234567890',
website: 'https://acme.example',
description: 'We sponsor racing',
logoUrl: '/logo.png',
industry: 'Automotive',
address: {
street: '123 Main St',
city: 'Metropolis',
country: 'US',
postalCode: '12345',
},
taxId: 'TAX-123',
socialLinks: {
twitter: '@acme',
linkedin: 'https://linkedin.com/acme',
instagram: '@acme_insta',
},
};
const notifications = {
emailNewSponsorships: true,
emailWeeklyReport: false,
emailRaceAlerts: true,
emailPaymentAlerts: false,
emailNewOpportunities: true,
emailContractExpiry: true,
};
const privacy = {
publicProfile: true,
showStats: false,
showActiveSponsorships: true,
allowDirectContact: false,
};
it('maps raw settings object into nested view models', () => {
const vm = new SponsorSettingsViewModel({ profile, notifications, privacy });
expect(vm.profile).toBeInstanceOf(SponsorProfileViewModel);
expect(vm.notifications).toBeInstanceOf(NotificationSettingsViewModel);
expect(vm.privacy).toBeInstanceOf(PrivacySettingsViewModel);
expect(vm.profile.companyName).toBe(profile.companyName);
expect(vm.notifications.emailNewSponsorships).toBe(true);
expect(vm.privacy.publicProfile).toBe(true);
});
it('exposes fullAddress computed from address fields', () => {
const profileVm = new SponsorProfileViewModel(profile);
expect(profileVm.fullAddress).toBe(
`${profile.address.street}, ${profile.address.city}, ${profile.address.postalCode}, ${profile.address.country}`,
);
});
it('preserves notification and privacy boolean flags', () => {
const notificationsVm = new NotificationSettingsViewModel(notifications);
const privacyVm = new PrivacySettingsViewModel(privacy);
expect(notificationsVm.emailWeeklyReport).toBe(false);
expect(notificationsVm.emailContractExpiry).toBe(true);
expect(privacyVm.showStats).toBe(false);
expect(privacyVm.allowDirectContact).toBe(false);
});
});

View File

@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { SponsorSponsorshipsViewModel } from './SponsorSponsorshipsViewModel';
import { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel';
describe('SponsorSponsorshipsViewModel', () => {
const dto = {
sponsorId: 'sp-1',
sponsorName: 'Acme Corp',
} as any;
const makeDetail = (overrides: Partial<SponsorshipDetailViewModel> = {}) => {
const base = new SponsorshipDetailViewModel({
id: 'detail-1',
leagueId: 'league-1',
leagueName: 'Pro League',
seasonId: 'season-1',
seasonName: 'Season 1',
} as any);
Object.assign(base, overrides);
return base;
};
it('maps core sponsor identifiers from DTO', () => {
const vm = new SponsorSponsorshipsViewModel(dto);
expect(vm.sponsorId).toBe(dto.sponsorId);
expect(vm.sponsorName).toBe(dto.sponsorName);
});
it('derives counts and investment summary from attached sponsorships', () => {
const vm = new SponsorSponsorshipsViewModel(dto);
vm.sponsorships = [
makeDetail({ id: 'd1', amount: 2_000, currency: 'USD', status: 'active' }),
makeDetail({ id: 'd2', amount: 1_000, currency: 'USD', status: 'expired' }),
makeDetail({ id: 'd3', amount: 3_000, currency: 'USD', status: 'active' }),
];
expect(vm.totalCount).toBe(3);
expect(vm.activeSponsorships).toHaveLength(2);
expect(vm.activeCount).toBe(2);
expect(vm.hasSponsorships).toBe(true);
const expectedInvestment = vm.sponsorships.reduce((sum, s) => sum + s.amount, 0);
expect(vm.totalInvestment).toBe(expectedInvestment);
expect(vm.formattedTotalInvestment).toBe(`USD ${expectedInvestment.toLocaleString()}`);
});
it('handles empty sponsorships gracefully', () => {
const vm = new SponsorSponsorshipsViewModel(dto);
expect(vm.totalCount).toBe(0);
expect(vm.activeSponsorships).toEqual([]);
expect(vm.activeCount).toBe(0);
expect(vm.hasSponsorships).toBe(false);
expect(vm.totalInvestment).toBe(0);
expect(vm.formattedTotalInvestment).toBe('USD 0');
});
});

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { SponsorViewModel } from './SponsorViewModel';
describe('SponsorViewModel', () => {
it('maps basic sponsor fields from DTO', () => {
const dto = {
id: 'sp-1',
name: 'Acme Corp',
logoUrl: '/logo.png',
websiteUrl: 'https://acme.example',
};
const vm = new SponsorViewModel(dto as any);
expect(vm.id).toBe(dto.id);
expect(vm.name).toBe(dto.name);
expect(vm.logoUrl).toBe(dto.logoUrl);
expect(vm.websiteUrl).toBe(dto.websiteUrl);
});
it('does not assign optional fields when they are undefined', () => {
const dto = {
id: 'sp-2',
name: 'Minimal Sponsor',
logoUrl: undefined,
websiteUrl: undefined,
};
const vm = new SponsorViewModel(dto as any);
expect(vm.id).toBe(dto.id);
expect(vm.name).toBe(dto.name);
expect('logoUrl' in vm).toBe(false);
expect('websiteUrl' in vm).toBe(false);
});
it('exposes simple UI helpers', () => {
const withWebsite = new SponsorViewModel({ id: 'sp-3', name: 'With Site', websiteUrl: 'https://example.com' } as any);
const withoutWebsite = new SponsorViewModel({ id: 'sp-4', name: 'No Site' } as any);
expect(withWebsite.displayName).toBe(withWebsite.name);
expect(withWebsite.hasWebsite).toBe(true);
expect(withoutWebsite.hasWebsite).toBe(false);
expect(withWebsite.websiteLinkText).toBe('Visit Website');
});
});

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel';
describe('SponsorshipDetailViewModel', () => {
const dto = {
id: 'detail-1',
leagueId: 'league-1',
leagueName: 'Pro League',
seasonId: 'season-1',
seasonName: 'Season 1',
} as any;
it('maps core identifiers from generated DTO', () => {
const vm = new SponsorshipDetailViewModel(dto);
expect(vm.id).toBe(dto.id);
expect(vm.leagueId).toBe(dto.leagueId);
expect(vm.leagueName).toBe(dto.leagueName);
expect(vm.seasonId).toBe(dto.seasonId);
expect(vm.seasonName).toBe(dto.seasonName);
});
it('uses default UI-specific sponsorship fields and formatting', () => {
const vm = new SponsorshipDetailViewModel(dto);
expect(vm.tier).toBe('secondary');
expect(vm.status).toBe('active');
expect(vm.amount).toBe(0);
expect(vm.currency).toBe('USD');
expect(vm.formattedAmount).toBe('USD 0');
expect(vm.tierBadgeVariant).toBe('secondary');
expect(vm.statusColor).toBe('green');
expect(vm.statusDisplay).toBe('Active');
});
it('derives badge variant and status styling from status and tier', () => {
const main = new SponsorshipDetailViewModel(dto);
main.tier = 'main';
main.status = 'pending';
main.amount = 5_000;
expect(main.tierBadgeVariant).toBe('primary');
expect(main.statusColor).toBe('yellow');
expect(main.statusDisplay).toBe('Pending');
expect(main.formattedAmount).toBe('USD 5,000');
const expired = new SponsorshipDetailViewModel(dto);
expired.status = 'expired';
expect(expired.statusColor).toBe('red');
const unknown = new SponsorshipDetailViewModel(dto);
unknown.status = 'unknown' as any;
expect(unknown.statusColor).toBe('gray');
});
});

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { SponsorshipPricingViewModel } from './SponsorshipPricingViewModel';
describe('SponsorshipPricingViewModel', () => {
const dto = {
mainSlotPrice: 10_000,
secondarySlotPrice: 6_000,
currency: 'USD',
} as any;
it('maps basic pricing fields from DTO', () => {
const vm = new SponsorshipPricingViewModel(dto);
expect(vm.mainSlotPrice).toBe(dto.mainSlotPrice);
expect(vm.secondarySlotPrice).toBe(dto.secondarySlotPrice);
expect(vm.currency).toBe(dto.currency);
});
it('exposes formatted prices and price difference', () => {
const vm = new SponsorshipPricingViewModel(dto);
expect(vm.formattedMainSlotPrice).toBe(`${dto.currency} ${dto.mainSlotPrice.toLocaleString()}`);
expect(vm.formattedSecondarySlotPrice).toBe(`${dto.currency} ${dto.secondarySlotPrice.toLocaleString()}`);
const expectedDiff = dto.mainSlotPrice - dto.secondarySlotPrice;
expect(vm.priceDifference).toBe(expectedDiff);
expect(vm.formattedPriceDifference).toBe(`${dto.currency} ${expectedDiff.toLocaleString()}`);
});
it('computes discount percentage for secondary slots', () => {
const vm = new SponsorshipPricingViewModel(dto);
const expectedDiscount = Math.round((1 - dto.secondarySlotPrice / dto.mainSlotPrice) * 100);
expect(vm.secondaryDiscountPercentage).toBe(expectedDiscount);
const zeroMain = new SponsorshipPricingViewModel({ ...dto, mainSlotPrice: 0 });
expect(zeroMain.secondaryDiscountPercentage).toBe(0);
});
});

View File

@@ -0,0 +1,60 @@
import { describe, it, expect, vi } from 'vitest';
import { SponsorshipRequestViewModel } from './SponsorshipRequestViewModel';
describe('SponsorshipRequestViewModel', () => {
const baseDto = {
id: 'req-1',
sponsorId: 'sp-1',
sponsorName: 'Acme Corp',
sponsorLogo: '/logo.png',
tier: 'main' as const,
offeredAmount: 50_000,
currency: 'USD',
formattedAmount: 'USD 50,000',
message: 'We would like to sponsor your league',
createdAt: new Date('2025-01-10T00:00:00Z'),
platformFee: 5_000,
netAmount: 45_000,
};
it('maps DTO fields and keeps date instance', () => {
const vm = new SponsorshipRequestViewModel(baseDto as any);
expect(vm.id).toBe(baseDto.id);
expect(vm.sponsorId).toBe(baseDto.sponsorId);
expect(vm.sponsorName).toBe(baseDto.sponsorName);
expect(vm.sponsorLogo).toBe(baseDto.sponsorLogo);
expect(vm.tier).toBe(baseDto.tier);
expect(vm.offeredAmount).toBe(baseDto.offeredAmount);
expect(vm.currency).toBe(baseDto.currency);
expect(vm.formattedAmount).toBe(baseDto.formattedAmount);
expect(vm.message).toBe(baseDto.message);
expect(vm.createdAt).toBeInstanceOf(Date);
expect(vm.platformFee).toBe(baseDto.platformFee);
expect(vm.netAmount).toBe(baseDto.netAmount);
});
it('formats created date for UI display', () => {
const fixedNow = new Date('2025-01-10T00:00:00Z');
vi.setSystemTime(fixedNow);
const vm = new SponsorshipRequestViewModel(baseDto as any);
const formatted = vm.formattedDate;
expect(typeof formatted).toBe('string');
expect(formatted.length).toBeGreaterThan(0);
vi.useRealTimers();
});
it('exposes net amount in dollars and tier helpers', () => {
const vmMain = new SponsorshipRequestViewModel(baseDto as any);
expect(vmMain.netAmountDollars).toBe(`$${(baseDto.netAmount / 100).toFixed(2)}`);
expect(vmMain.tierDisplay).toBe('Main Sponsor');
expect(vmMain.tierBadgeVariant).toBe('primary');
const vmSecondary = new SponsorshipRequestViewModel({ ...baseDto, tier: 'secondary' } as any);
expect(vmSecondary.tierDisplay).toBe('Secondary');
expect(vmSecondary.tierBadgeVariant).toBe('secondary');
});
});

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, vi } from 'vitest';
import { SponsorshipViewModel } from './SponsorshipViewModel';
describe('SponsorshipViewModel', () => {
const baseData = {
id: 'sp-1',
type: 'leagues' as const,
entityId: 'league-1',
entityName: 'Pro League',
tier: 'main' as const,
status: 'active' as const,
applicationDate: '2025-01-01T00:00:00Z',
approvalDate: '2025-01-02T00:00:00Z',
startDate: '2025-01-10T00:00:00Z',
endDate: '2025-02-10T00:00:00Z',
price: 5_000,
impressions: 50_000,
impressionsChange: 10,
engagement: 3.5,
details: 'Full season branding',
entityOwner: 'League Owner',
applicationMessage: 'Excited to sponsor',
};
it('maps core fields and converts dates', () => {
const vm = new SponsorshipViewModel(baseData);
expect(vm.id).toBe(baseData.id);
expect(vm.type).toBe(baseData.type);
expect(vm.entityId).toBe(baseData.entityId);
expect(vm.entityName).toBe(baseData.entityName);
expect(vm.tier).toBe(baseData.tier);
expect(vm.status).toBe(baseData.status);
expect(vm.applicationDate).toBeInstanceOf(Date);
expect(vm.approvalDate).toBeInstanceOf(Date);
expect(vm.startDate).toBeInstanceOf(Date);
expect(vm.endDate).toBeInstanceOf(Date);
expect(vm.price).toBe(baseData.price);
expect(vm.impressions).toBe(baseData.impressions);
expect(vm.impressionsChange).toBe(baseData.impressionsChange);
expect(vm.engagement).toBe(baseData.engagement);
expect(vm.details).toBe(baseData.details);
expect(vm.entityOwner).toBe(baseData.entityOwner);
expect(vm.applicationMessage).toBe(baseData.applicationMessage);
});
it('exposes formatted impressions and price', () => {
const vm = new SponsorshipViewModel(baseData);
expect(vm.formattedImpressions).toBe(baseData.impressions.toLocaleString());
expect(vm.formattedPrice).toBe(`$${baseData.price}`);
});
it('computes daysRemaining and expiringSoon based on endDate', () => {
const now = new Date('2025-01-01T00:00:00Z');
vi.setSystemTime(now);
const vm = new SponsorshipViewModel({
...baseData,
endDate: '2025-01-15T00:00:00Z',
});
expect(vm.daysRemaining).toBeGreaterThan(0);
expect(vm.isExpiringSoon).toBe(true);
const farFuture = new SponsorshipViewModel({
...baseData,
endDate: '2025-12-31T00:00:00Z',
});
expect(farFuture.isExpiringSoon).toBe(false);
const past = new SponsorshipViewModel({
...baseData,
endDate: '2024-12-31T00:00:00Z',
});
expect(past.daysRemaining).toBeLessThanOrEqual(0);
expect(past.isExpiringSoon).toBe(false);
vi.useRealTimers();
});
it('derives human-readable status and type labels', () => {
const active = new SponsorshipViewModel({ ...baseData, status: 'active' });
const pending = new SponsorshipViewModel({ ...baseData, status: 'pending_approval' });
const approved = new SponsorshipViewModel({ ...baseData, status: 'approved' });
const rejected = new SponsorshipViewModel({ ...baseData, status: 'rejected' });
const expired = new SponsorshipViewModel({ ...baseData, status: 'expired' });
expect(active.statusLabel).toBe('Active');
expect(pending.statusLabel).toBe('Awaiting Approval');
expect(approved.statusLabel).toBe('Approved');
expect(rejected.statusLabel).toBe('Declined');
expect(expired.statusLabel).toBe('Expired');
const league = new SponsorshipViewModel({ ...baseData, type: 'leagues' });
const team = new SponsorshipViewModel({ ...baseData, type: 'teams' });
const driver = new SponsorshipViewModel({ ...baseData, type: 'drivers' });
const race = new SponsorshipViewModel({ ...baseData, type: 'races' });
const platform = new SponsorshipViewModel({ ...baseData, type: 'platform' });
expect(league.typeLabel).toBe('League');
expect(team.typeLabel).toBe('Team');
expect(driver.typeLabel).toBe('Driver');
expect(race.typeLabel).toBe('Race');
expect(platform.typeLabel).toBe('Platform');
});
it('formats sponsorship period as month-year range', () => {
const vm = new SponsorshipViewModel({
...baseData,
startDate: '2025-01-10T00:00:00Z',
endDate: '2025-03-10T00:00:00Z',
});
const [start, end] = vm.periodDisplay.split(' - ');
expect(typeof start).toBe('string');
expect(typeof end).toBe('string');
expect(start.length).toBeGreaterThan(0);
expect(end.length).toBeGreaterThan(0);
});
});

View File

@@ -13,10 +13,13 @@ export class StandingEntryViewModel {
private currentUserId: string;
private previousPosition?: number;
constructor(dto: LeagueStandingDTO, leaderPoints: number, nextPoints: number, currentUserId: string, previousPosition?: number) {
constructor(dto: LeagueStandingDTO & { position: number; points: number; wins?: number; podiums?: number; races?: number }, leaderPoints: number, nextPoints: number, currentUserId: string, previousPosition?: number) {
this.driverId = dto.driverId;
this.position = dto.position;
this.points = dto.points;
this.wins = dto.wins ?? 0;
this.podiums = dto.podiums ?? 0;
this.races = dto.races ?? 0;
this.leaderPoints = leaderPoints;
this.nextPoints = nextPoints;
this.currentUserId = currentUserId;
@@ -31,9 +34,6 @@ export class StandingEntryViewModel {
// Note: The generated DTO is incomplete
// These fields will need to be added when the OpenAPI spec is updated
driver?: any;
wins: number = 0;
podiums: number = 0;
races: number = 0;
/** UI-specific: Points difference to leader */
get pointsGapToLeader(): number {

View File

@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { TeamCardViewModel } from './TeamCardViewModel';
import type { TeamListItemDTO } from '@/lib/types/generated/GetAllTeamsOutputDTO';
const createTeamCardDto = (): { id: string; name: string; tag: string; description: string } => ({
id: 'team-1',
name: 'Team Alpha',
tag: 'ALPHA',
description: 'Endurance specialists',
});
const createTeamListItemDto = (overrides: Partial<TeamListItemDTO> = {}): TeamListItemDTO => ({
id: 'team-2',
name: 'Team Beta',
tag: 'BETA',
description: 'A test team',
memberCount: 5,
leagues: ['league-1'],
...overrides,
});
describe('TeamCardViewModel', () => {
it('maps fields from simple TeamCardDTO', () => {
const dto = createTeamCardDto();
const vm = new TeamCardViewModel(dto);
expect(vm.id).toBe('team-1');
expect(vm.name).toBe('Team Alpha');
expect(vm.tag).toBe('ALPHA');
expect(vm.description).toBe('Endurance specialists');
});
it('maps fields from TeamListItemDTO', () => {
const dto = createTeamListItemDto({ id: 'team-123', name: 'Custom Team', tag: 'CT' });
const vm = new TeamCardViewModel(dto);
expect(vm.id).toBe('team-123');
expect(vm.name).toBe('Custom Team');
expect(vm.tag).toBe('CT');
expect(vm.description).toBe('A test team');
});
});

View File

@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { TeamDetailsViewModel } from './TeamDetailsViewModel';
import type { GetTeamDetailsOutputDTO } from '@/lib/types/generated/GetTeamDetailsOutputDTO';
const createTeamDetailsDto = (overrides: Partial<GetTeamDetailsOutputDTO> = {}): GetTeamDetailsOutputDTO => ({
team: {
id: 'team-1',
name: 'Test Team',
tag: 'TT',
description: 'A test team',
ownerId: 'owner-1',
leagues: ['league-1'],
createdAt: '2024-01-01T00:00:00Z',
specialization: 'endurance',
region: 'EU',
languages: ['en'],
...(overrides.team ?? {}),
},
membership:
'membership' in overrides
? (overrides.membership ?? null)
: {
role: 'member',
joinedAt: '2024-01-01T00:00:00Z',
isActive: true,
},
canManage: overrides.canManage ?? true,
});
describe('TeamDetailsViewModel', () => {
it('maps team fields and membership from DTO', () => {
const dto = createTeamDetailsDto();
const vm = new TeamDetailsViewModel(dto, 'current-user');
expect(vm.id).toBe('team-1');
expect(vm.name).toBe('Test Team');
expect(vm.tag).toBe('TT');
expect(vm.description).toBe('A test team');
expect(vm.ownerId).toBe('owner-1');
expect(vm.leagues).toEqual(['league-1']);
expect(vm.createdAt).toBe('2024-01-01T00:00:00Z');
expect(vm.specialization).toBe('endurance');
expect(vm.region).toBe('EU');
expect(vm.languages).toEqual(['en']);
expect(vm.membership).toEqual({
role: 'member',
joinedAt: '2024-01-01T00:00:00Z',
isActive: true,
});
});
it('derives ownership, membership and role flags', () => {
const dto = createTeamDetailsDto({
membership: {
role: 'owner',
joinedAt: '2024-01-01T00:00:00Z',
isActive: true,
},
});
const vm = new TeamDetailsViewModel(dto, 'owner-user');
expect(vm.isOwner).toBe(true);
expect(vm.isMember).toBe(true);
expect(vm.userRole).toBe('owner');
});
it('exposes canManage flag from DTO', () => {
const canManageDto = createTeamDetailsDto({ canManage: true });
const cannotManageDto = createTeamDetailsDto({ canManage: false });
const canManageVm = new TeamDetailsViewModel(canManageDto, 'user-1');
const cannotManageVm = new TeamDetailsViewModel(cannotManageDto, 'user-1');
expect(canManageVm.canManage).toBe(true);
expect(cannotManageVm.canManage).toBe(false);
});
it('handles null membership as non-member with default role', () => {
const dto = createTeamDetailsDto({ membership: null });
const vm = new TeamDetailsViewModel(dto, 'user-1');
expect(vm.isMember).toBe(false);
expect(vm.userRole).toBe('none');
expect(vm.isOwner).toBe(false);
});
});

View File

@@ -12,7 +12,7 @@ export class TeamDetailsViewModel {
region?: string;
languages?: string[];
membership: { role: string; joinedAt: string; isActive: boolean } | null;
canManage: boolean;
private _canManage: boolean;
private currentUserId: string;
constructor(dto: GetTeamDetailsOutputDTO, currentUserId: string) {
@@ -27,7 +27,7 @@ export class TeamDetailsViewModel {
this.region = dto.team.region;
this.languages = dto.team.languages;
this.membership = dto.membership;
this.canManage = dto.canManage;
this._canManage = dto.canManage;
this.currentUserId = currentUserId;
}
@@ -38,7 +38,7 @@ export class TeamDetailsViewModel {
/** UI-specific: Whether can manage team */
get canManage(): boolean {
return this.canManage;
return this._canManage;
}
/** UI-specific: Whether current user is member */

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { TeamJoinRequestViewModel, type TeamJoinRequestDTO } from './TeamJoinRequestViewModel';
const createTeamJoinRequestDto = (overrides: Partial<TeamJoinRequestDTO> = {}): TeamJoinRequestDTO => ({
id: 'request-1',
teamId: 'team-1',
driverId: 'driver-1',
requestedAt: '2024-01-01T12:00:00Z',
message: 'Please let me join',
...overrides,
});
describe('TeamJoinRequestViewModel', () => {
it('maps fields from DTO', () => {
const dto = createTeamJoinRequestDto({ id: 'req-123', driverId: 'driver-123' });
const vm = new TeamJoinRequestViewModel(dto, 'current-user', true);
expect(vm.id).toBe('req-123');
expect(vm.teamId).toBe('team-1');
expect(vm.driverId).toBe('driver-123');
expect(vm.requestedAt).toBe('2024-01-01T12:00:00Z');
expect(vm.message).toBe('Please let me join');
});
it('allows approval only for owners', () => {
const dto = createTeamJoinRequestDto();
const ownerVm = new TeamJoinRequestViewModel(dto, 'owner-user', true);
const nonOwnerVm = new TeamJoinRequestViewModel(dto, 'regular-user', false);
expect(ownerVm.canApprove).toBe(true);
expect(nonOwnerVm.canApprove).toBe(false);
});
it('exposes a pending status with yellow color', () => {
const dto = createTeamJoinRequestDto();
const vm = new TeamJoinRequestViewModel(dto, 'owner-user', true);
expect(vm.status).toBe('Pending');
expect(vm.statusColor).toBe('yellow');
});
it('provides approve and reject button labels', () => {
const dto = createTeamJoinRequestDto();
const vm = new TeamJoinRequestViewModel(dto, 'owner-user', true);
expect(vm.approveButtonText).toBe('Approve');
expect(vm.rejectButtonText).toBe('Reject');
});
it('formats requestedAt as localized date-time', () => {
const dto = createTeamJoinRequestDto({ requestedAt: '2024-01-01T12:00:00Z' });
const vm = new TeamJoinRequestViewModel(dto, 'owner-user', true);
const formatted = vm.formattedRequestedAt;
expect(typeof formatted).toBe('string');
expect(formatted.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { TeamMemberViewModel } from './TeamMemberViewModel';
import type { TeamMemberDTO } from '@/lib/types/generated/GetTeamMembersOutputDTO';
const createTeamMemberDto = (overrides: Partial<TeamMemberDTO> = {}): TeamMemberDTO => ({
driverId: 'driver-1',
driverName: 'Test Driver',
role: 'member',
joinedAt: '2024-01-01T00:00:00Z',
isActive: true,
avatarUrl: 'https://example.com/avatar.png',
...overrides,
});
describe('TeamMemberViewModel', () => {
it('maps fields from DTO', () => {
const dto = createTeamMemberDto({ driverId: 'driver-123', driverName: 'Driver 123', role: 'owner' });
const vm = new TeamMemberViewModel(dto, 'current-user', 'owner-1');
expect(vm.driverId).toBe('driver-123');
expect(vm.driverName).toBe('Driver 123');
expect(vm.role).toBe('owner');
expect(vm.joinedAt).toBe('2024-01-01T00:00:00Z');
expect(vm.isActive).toBe(true);
expect(vm.avatarUrl).toBe('https://example.com/avatar.png');
});
it('derives roleBadgeVariant based on role', () => {
const ownerVm = new TeamMemberViewModel(createTeamMemberDto({ role: 'owner' }), 'current-user', 'owner-1');
const managerVm = new TeamMemberViewModel(createTeamMemberDto({ role: 'manager' }), 'current-user', 'owner-1');
const memberVm = new TeamMemberViewModel(createTeamMemberDto({ role: 'member' }), 'current-user', 'owner-1');
expect(ownerVm.roleBadgeVariant).toBe('primary');
expect(managerVm.roleBadgeVariant).toBe('secondary');
expect(memberVm.roleBadgeVariant).toBe('default');
});
it('identifies owner correctly based on teamOwnerId', () => {
const dto = createTeamMemberDto({ driverId: 'owner-1', role: 'owner' });
const ownerVm = new TeamMemberViewModel(dto, 'some-user', 'owner-1');
const nonOwnerVm = new TeamMemberViewModel(dto, 'some-user', 'another-owner');
expect(ownerVm.isOwner).toBe(true);
expect(nonOwnerVm.isOwner).toBe(false);
});
it('determines canManage only for team owner and non-self members', () => {
const memberDto = createTeamMemberDto({ driverId: 'member-1' });
const ownerManagingMember = new TeamMemberViewModel(memberDto, 'owner-1', 'owner-1');
const ownerSelf = new TeamMemberViewModel(createTeamMemberDto({ driverId: 'owner-1' }), 'owner-1', 'owner-1');
const nonOwner = new TeamMemberViewModel(memberDto, 'another-user', 'owner-1');
expect(ownerManagingMember.canManage).toBe(true);
expect(ownerSelf.canManage).toBe(false);
expect(nonOwner.canManage).toBe(false);
});
it('identifies current user correctly', () => {
const dto = createTeamMemberDto({ driverId: 'current-user' });
const vm = new TeamMemberViewModel(dto, 'current-user', 'owner-1');
expect(vm.isCurrentUser).toBe(true);
});
it('formats joinedAt as a localized date string', () => {
const dto = createTeamMemberDto({ joinedAt: '2024-01-01T00:00:00Z' });
const vm = new TeamMemberViewModel(dto, 'current-user', 'owner-1');
const formatted = vm.formattedJoinedAt;
expect(typeof formatted).toBe('string');
expect(formatted.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { TeamSummaryViewModel } from './TeamSummaryViewModel';
import type { TeamListItemDTO } from '@/lib/types/generated/GetAllTeamsOutputDTO';
const createTeamListItemDto = (overrides: Partial<TeamListItemDTO> = {}): TeamListItemDTO => ({
id: 'team-1',
name: 'Test Team',
tag: 'TT',
memberCount: 5,
description: 'A test team',
specialization: 'endurance',
region: 'EU',
languages: ['en'],
leagues: ['league-1'],
...overrides,
});
describe('TeamSummaryViewModel', () => {
it('maps fields from DTO', () => {
const dto = createTeamListItemDto({ id: 'team-123', name: 'Custom Team', tag: 'CT', memberCount: 3 });
const vm = new TeamSummaryViewModel(dto);
expect(vm.id).toBe('team-123');
expect(vm.name).toBe('Custom Team');
expect(vm.tag).toBe('CT');
expect(vm.memberCount).toBe(3);
expect(vm.description).toBe('A test team');
expect(vm.specialization).toBe('endurance');
expect(vm.region).toBe('EU');
expect(vm.languages).toEqual(['en']);
expect(vm.leagues).toEqual(['league-1']);
});
it('derives isFull and related displays when team is full', () => {
const dto = createTeamListItemDto({ memberCount: 10 });
const vm = new TeamSummaryViewModel(dto);
expect(vm.isFull).toBe(true);
expect(vm.memberCountDisplay).toBe('10/10');
expect(vm.statusIndicator).toBe('Full');
expect(vm.statusColor).toBe('red');
});
it('derives isFull and related displays when team is not full', () => {
const dto = createTeamListItemDto({ memberCount: 3 });
const vm = new TeamSummaryViewModel(dto);
expect(vm.isFull).toBe(false);
expect(vm.memberCountDisplay).toBe('3/10');
expect(vm.statusIndicator).toBe('Open');
expect(vm.statusColor).toBe('green');
});
it('formats tagDisplay with square brackets', () => {
const dto = createTeamListItemDto({ tag: 'TT' });
const vm = new TeamSummaryViewModel(dto);
expect(vm.tagDisplay).toBe('[TT]');
});
});

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { UpcomingRaceCardViewModel } from './UpcomingRaceCardViewModel';
describe('UpcomingRaceCardViewModel', () => {
const baseDto = {
id: 'race-1',
track: 'Spa-Francorchamps',
car: 'GT3',
scheduledAt: '2025-01-01T20:00:00Z',
};
it('maps DTO fields', () => {
const viewModel = new UpcomingRaceCardViewModel(baseDto);
expect(viewModel.id).toBe('race-1');
expect(viewModel.track).toBe('Spa-Francorchamps');
expect(viewModel.car).toBe('GT3');
expect(viewModel.scheduledAt).toBe('2025-01-01T20:00:00Z');
});
it('formats date label with month and day', () => {
const viewModel = new UpcomingRaceCardViewModel(baseDto);
const formatted = viewModel.formattedDate;
expect(formatted).toMatch(/\d{1,2}/);
});
});

View File

@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { UpdateAvatarViewModel } from './UpdateAvatarViewModel';
describe('UpdateAvatarViewModel', () => {
it('creates instance with success true and no error', () => {
const dto = { success: true };
const viewModel = new UpdateAvatarViewModel(dto);
expect(viewModel.success).toBe(true);
expect(viewModel.error).toBeUndefined();
});
it('creates instance with success false and error', () => {
const dto = { success: false, error: 'Update failed' };
const viewModel = new UpdateAvatarViewModel(dto);
expect(viewModel.success).toBe(false);
expect(viewModel.error).toBe('Update failed');
});
it('isSuccessful reflects success flag', () => {
const successVm = new UpdateAvatarViewModel({ success: true });
const failureVm = new UpdateAvatarViewModel({ success: false });
expect(successVm.isSuccessful).toBe(true);
expect(failureVm.isSuccessful).toBe(false);
});
it('hasError reflects presence of error message', () => {
const noErrorVm = new UpdateAvatarViewModel({ success: true });
const errorVm = new UpdateAvatarViewModel({ success: false, error: 'Something went wrong' });
expect(noErrorVm.hasError).toBe(false);
expect(errorVm.hasError).toBe(true);
});
it('treats empty error string as no error', () => {
const viewModel = new UpdateAvatarViewModel({ success: false, error: '' });
expect(viewModel.hasError).toBe(false);
});
});

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { UpdateTeamViewModel } from './UpdateTeamViewModel';
describe('UpdateTeamViewModel', () => {
it('maps success from DTO', () => {
const dto = { success: true };
const vm = new UpdateTeamViewModel(dto);
expect(vm.success).toBe(true);
});
it('returns success successMessage when update succeeded', () => {
const dto = { success: true };
const vm = new UpdateTeamViewModel(dto);
expect(vm.successMessage).toBe('Team updated successfully!');
});
it('returns failure successMessage when update failed', () => {
const dto = { success: false };
const vm = new UpdateTeamViewModel(dto);
expect(vm.successMessage).toBe('Failed to update team.');
});
});

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { UploadMediaViewModel } from './UploadMediaViewModel';
describe('UploadMediaViewModel', () => {
it('creates instance with successful upload and media data', () => {
const dto = {
success: true,
mediaId: 'media-123',
url: 'https://example.com/uploaded.jpg',
};
const viewModel = new UploadMediaViewModel(dto);
expect(viewModel.success).toBe(true);
expect(viewModel.mediaId).toBe('media-123');
expect(viewModel.url).toBe('https://example.com/uploaded.jpg');
expect(viewModel.error).toBeUndefined();
});
it('creates instance with failed upload and error', () => {
const dto = {
success: false,
error: 'Upload failed',
};
const viewModel = new UploadMediaViewModel(dto);
expect(viewModel.success).toBe(false);
expect(viewModel.mediaId).toBeUndefined();
expect(viewModel.url).toBeUndefined();
expect(viewModel.error).toBe('Upload failed');
});
it('isSuccessful reflects success flag', () => {
const successVm = new UploadMediaViewModel({ success: true });
const failureVm = new UploadMediaViewModel({ success: false });
expect(successVm.isSuccessful).toBe(true);
expect(failureVm.isSuccessful).toBe(false);
});
it('hasError reflects presence of error message', () => {
const noErrorVm = new UploadMediaViewModel({ success: true });
const errorVm = new UploadMediaViewModel({ success: false, error: 'Something went wrong' });
expect(noErrorVm.hasError).toBe(false);
expect(errorVm.hasError).toBe(true);
});
it('treats empty error string as no error', () => {
const viewModel = new UploadMediaViewModel({ success: false, error: '' });
expect(viewModel.hasError).toBe(false);
});
});

View File

@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import { UserProfileViewModel } from './UserProfileViewModel';
describe('UserProfileViewModel', () => {
it('maps required properties from the DTO', () => {
const dto = {
id: 'user-1',
name: 'Roo Racer',
};
const viewModel = new UserProfileViewModel(dto);
expect(viewModel.id).toBe('user-1');
expect(viewModel.name).toBe('Roo Racer');
expect(viewModel.avatarUrl).toBeUndefined();
expect(viewModel.iracingId).toBeUndefined();
expect(viewModel.rating).toBeUndefined();
});
it('maps optional properties only when provided', () => {
const dto = {
id: 'user-2',
name: 'Rated Driver',
avatarUrl: 'https://example.com/avatar.jpg',
iracingId: '12345',
rating: 2734,
};
const viewModel = new UserProfileViewModel(dto);
expect(viewModel.avatarUrl).toBe('https://example.com/avatar.jpg');
expect(viewModel.iracingId).toBe('12345');
expect(viewModel.rating).toBe(2734);
});
it('formats rating as whole-number string when present, or Unrated otherwise', () => {
const unrated = new UserProfileViewModel({ id: 'user-1', name: 'Unrated Driver' });
const rated = new UserProfileViewModel({ id: 'user-2', name: 'Rated Driver', rating: 2734.56 });
expect(unrated.formattedRating).toBe('Unrated');
expect(rated.formattedRating).toBe('2735');
});
it('indicates whether an iRacing ID is present', () => {
const withoutId = new UserProfileViewModel({ id: 'user-1', name: 'No ID' });
const withId = new UserProfileViewModel({ id: 'user-2', name: 'Has ID', iracingId: '67890' });
expect(withoutId.hasIracingId).toBe(false);
expect(withId.hasIracingId).toBe(true);
});
it('computes profile completeness based on available optional fields', () => {
const base = new UserProfileViewModel({ id: 'user-1', name: 'Base User' });
const withAvatar = new UserProfileViewModel({ id: 'user-2', name: 'Avatar User', avatarUrl: 'url' });
const withAvatarAndId = new UserProfileViewModel({
id: 'user-3',
name: 'Avatar + ID',
avatarUrl: 'url',
iracingId: '123',
});
const withAll = new UserProfileViewModel({
id: 'user-4',
name: 'Full Profile',
avatarUrl: 'url',
iracingId: '123',
rating: 2000,
});
expect(base.profileCompleteness).toBe(25);
expect(withAvatar.profileCompleteness).toBe(50);
expect(withAvatarAndId.profileCompleteness).toBe(75);
expect(withAll.profileCompleteness).toBe(100);
});
it('derives avatar initials from the name', () => {
const viewModel = new UserProfileViewModel({ id: 'user-1', name: 'Roo Racer' });
expect(viewModel.avatarInitials).toBe('RR');
});
});

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { WalletTransactionViewModel } from './WalletTransactionViewModel';
const createTx = (overrides: Partial<any> = {}): any => ({
id: 'tx-1',
type: 'sponsorship',
description: 'Test',
amount: 100,
fee: 10,
netAmount: 90,
date: new Date('2024-01-01T00:00:00Z'),
status: 'completed',
reference: 'ref-1',
...overrides,
});
describe('WalletTransactionViewModel', () => {
it('maps DTO fields into the view model', () => {
const vm = new WalletTransactionViewModel(createTx());
expect(vm.id).toBe('tx-1');
expect(vm.type).toBe('sponsorship');
expect(vm.amount).toBe(100);
expect(vm.netAmount).toBe(90);
expect(vm.status).toBe('completed');
});
it('formats amount with sign and currency symbol', () => {
const incoming = new WalletTransactionViewModel(createTx({ amount: 50 }));
const outgoing = new WalletTransactionViewModel(createTx({ amount: -20 }));
expect(incoming.formattedAmount).toBe('+$50.00');
expect(outgoing.formattedAmount).toBe('$20.00');
});
it('derives amount color and isIncoming from amount sign', () => {
const incoming = new WalletTransactionViewModel(createTx({ amount: 10 }));
const outgoing = new WalletTransactionViewModel(createTx({ amount: -5 }));
expect(incoming.amountColor).toBe('green');
expect(incoming.isIncoming).toBe(true);
expect(outgoing.amountColor).toBe('red');
expect(outgoing.isIncoming).toBe(false);
});
it('derives typeDisplay and formattedDate', () => {
const vm = new WalletTransactionViewModel(createTx({ type: 'membership' as any }));
expect(vm.typeDisplay).toBe('Membership');
expect(typeof vm.formattedDate).toBe('string');
});
});

View File

@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { WalletViewModel } from './WalletViewModel';
import { WalletTransactionViewModel } from './WalletTransactionViewModel';
const createWalletDto = (overrides: Partial<any> = {}): any => ({
id: 'wallet-1',
leagueId: 'league-1',
balance: 100.5,
totalRevenue: 1000,
totalPlatformFees: 50,
totalWithdrawn: 200,
createdAt: '2024-01-01T00:00:00Z',
currency: 'EUR',
transactions: [],
...overrides,
});
const createTransactionDto = (overrides: Partial<any> = {}): any => ({
id: 'tx-1',
type: 'sponsorship',
description: 'Test',
amount: 10,
fee: 1,
netAmount: 9,
date: new Date('2024-01-01T00:00:00Z'),
status: 'completed',
reference: 'ref',
...overrides,
});
describe('WalletViewModel', () => {
it('maps wallet DTO fields and wraps transactions into view models', () => {
const dto = createWalletDto({
transactions: [createTransactionDto({ id: 'tx-1' }), createTransactionDto({ id: 'tx-2', amount: -5 })],
});
const vm = new WalletViewModel(dto);
expect(vm.id).toBe('wallet-1');
expect(vm.leagueId).toBe('league-1');
expect(vm.balance).toBe(100.5);
expect(vm.transactions).toHaveLength(2);
expect(vm.transactions[0]).toBeInstanceOf(WalletTransactionViewModel);
});
it('formats balance with currency and 2 decimals', () => {
const vm = new WalletViewModel(createWalletDto({ balance: 250, currency: 'USD' }));
expect(vm.formattedBalance).toBe('USD 250.00');
});
it('derives balanceColor based on sign of balance', () => {
const positive = new WalletViewModel(createWalletDto({ balance: 10 }));
const zero = new WalletViewModel(createWalletDto({ balance: 0 }));
const negative = new WalletViewModel(createWalletDto({ balance: -5 }));
expect(positive.balanceColor).toBe('green');
expect(zero.balanceColor).toBe('green');
expect(negative.balanceColor).toBe('red');
});
it('exposes recentTransactions and totalTransactions helpers', () => {
const transactions = [
createTransactionDto({ id: 'tx-1' }),
createTransactionDto({ id: 'tx-2' }),
createTransactionDto({ id: 'tx-3' }),
createTransactionDto({ id: 'tx-4' }),
createTransactionDto({ id: 'tx-5' }),
createTransactionDto({ id: 'tx-6' }),
];
const vm = new WalletViewModel(createWalletDto({ transactions }));
expect(vm.totalTransactions).toBe(6);
expect(vm.recentTransactions).toHaveLength(5);
expect(vm.recentTransactions[0]).toBeInstanceOf(WalletTransactionViewModel);
});
});