view data fixes
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* DriverRegistrationStatusDisplay
|
||||||
|
*
|
||||||
|
* Deterministic mapping of driver registration boolean state
|
||||||
|
* to UI labels and variants.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class DriverRegistrationStatusDisplay {
|
||||||
|
static statusMessage(isRegistered: boolean): string {
|
||||||
|
return isRegistered ? "Registered for this race" : "Not registered";
|
||||||
|
}
|
||||||
|
|
||||||
|
static statusBadgeVariant(isRegistered: boolean): string {
|
||||||
|
return isRegistered ? "success" : "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
static registrationButtonText(isRegistered: boolean): string {
|
||||||
|
return isRegistered ? "Withdraw" : "Register";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,6 +165,10 @@ export class ProfileDisplay {
|
|||||||
text: 'Owner',
|
text: 'Owner',
|
||||||
badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
|
badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
|
||||||
},
|
},
|
||||||
|
manager: {
|
||||||
|
text: 'Manager',
|
||||||
|
badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30',
|
||||||
|
},
|
||||||
admin: {
|
admin: {
|
||||||
text: 'Admin',
|
text: 'Admin',
|
||||||
badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
|
badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ describe('DriverRegistrationService', () => {
|
|||||||
const mockDto = {
|
const mockDto = {
|
||||||
isRegistered: true,
|
isRegistered: true,
|
||||||
raceId: 'race-456',
|
raceId: 'race-456',
|
||||||
driverId: 'driver-123',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
mockApiClient.getRegistrationStatus.mockResolvedValue(mockDto);
|
mockApiClient.getRegistrationStatus.mockResolvedValue(mockDto);
|
||||||
@@ -36,7 +35,6 @@ describe('DriverRegistrationService', () => {
|
|||||||
expect(result.raceId).toBe('race-456');
|
expect(result.raceId).toBe('race-456');
|
||||||
expect(result.driverId).toBe('driver-123');
|
expect(result.driverId).toBe('driver-123');
|
||||||
expect(result.statusMessage).toBe('Registered for this race');
|
expect(result.statusMessage).toBe('Registered for this race');
|
||||||
expect(result.statusColor).toBe('green');
|
|
||||||
expect(result.statusBadgeVariant).toBe('success');
|
expect(result.statusBadgeVariant).toBe('success');
|
||||||
expect(result.registrationButtonText).toBe('Withdraw');
|
expect(result.registrationButtonText).toBe('Withdraw');
|
||||||
expect(result.canRegister).toBe(false);
|
expect(result.canRegister).toBe(false);
|
||||||
@@ -49,7 +47,6 @@ describe('DriverRegistrationService', () => {
|
|||||||
const mockDto = {
|
const mockDto = {
|
||||||
isRegistered: false,
|
isRegistered: false,
|
||||||
raceId: 'race-456',
|
raceId: 'race-456',
|
||||||
driverId: 'driver-123',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
mockApiClient.getRegistrationStatus.mockResolvedValue(mockDto);
|
mockApiClient.getRegistrationStatus.mockResolvedValue(mockDto);
|
||||||
@@ -58,7 +55,6 @@ describe('DriverRegistrationService', () => {
|
|||||||
|
|
||||||
expect(result.isRegistered).toBe(false);
|
expect(result.isRegistered).toBe(false);
|
||||||
expect(result.statusMessage).toBe('Not registered');
|
expect(result.statusMessage).toBe('Not registered');
|
||||||
expect(result.statusColor).toBe('red');
|
|
||||||
expect(result.statusBadgeVariant).toBe('warning');
|
expect(result.statusBadgeVariant).toBe('warning');
|
||||||
expect(result.registrationButtonText).toBe('Register');
|
expect(result.registrationButtonText).toBe('Register');
|
||||||
expect(result.canRegister).toBe(true);
|
expect(result.canRegister).toBe(true);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
|||||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||||
import { Service } from '@/lib/contracts/services/Service';
|
import { Service } from '@/lib/contracts/services/Service';
|
||||||
|
import type { DriverRegistrationStatusViewData } from '@/lib/view-data/DriverRegistrationStatusViewData';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export class DriverRegistrationService implements Service {
|
export class DriverRegistrationService implements Service {
|
||||||
@@ -22,7 +23,15 @@ export class DriverRegistrationService implements Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getDriverRegistrationStatus(driverId: string, raceId: string): Promise<DriverRegistrationStatusViewModel> {
|
async getDriverRegistrationStatus(driverId: string, raceId: string): Promise<DriverRegistrationStatusViewModel> {
|
||||||
const data = await this.apiClient.getRegistrationStatus(driverId, raceId);
|
const dto = await this.apiClient.getRegistrationStatus(driverId, raceId);
|
||||||
return new DriverRegistrationStatusViewModel(data);
|
|
||||||
|
const viewData: DriverRegistrationStatusViewData = {
|
||||||
|
isRegistered: dto.isRegistered,
|
||||||
|
raceId: dto.raceId,
|
||||||
|
driverId,
|
||||||
|
canRegister: !dto.isRegistered,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new DriverRegistrationStatusViewModel(viewData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Driver Registration Status View Data
|
||||||
|
*
|
||||||
|
* JSON-serializable, template-ready data structure.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ViewData } from "../contracts/view-data/ViewData";
|
||||||
|
|
||||||
|
export interface DriverRegistrationStatusViewData extends ViewData {
|
||||||
|
isRegistered: boolean;
|
||||||
|
raceId: string;
|
||||||
|
driverId: string;
|
||||||
|
canRegister: boolean;
|
||||||
|
}
|
||||||
9
apps/website/lib/view-data/EmailSignupViewData.ts
Normal file
9
apps/website/lib/view-data/EmailSignupViewData.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||||
|
|
||||||
|
export type EmailSignupStatus = 'success' | 'error' | 'info';
|
||||||
|
|
||||||
|
export interface EmailSignupViewData extends ViewData {
|
||||||
|
email: string;
|
||||||
|
message: string;
|
||||||
|
status: EmailSignupStatus;
|
||||||
|
}
|
||||||
21
apps/website/lib/view-data/HomeDiscoveryViewData.ts
Normal file
21
apps/website/lib/view-data/HomeDiscoveryViewData.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||||
|
|
||||||
|
export interface HomeDiscoveryViewData extends ViewData {
|
||||||
|
topLeagues: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
teams: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
}>;
|
||||||
|
upcomingRaces: Array<{
|
||||||
|
id: string;
|
||||||
|
track: string;
|
||||||
|
car: string;
|
||||||
|
formattedDate: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||||
|
|
||||||
|
export interface ImportRaceResultsSummaryViewData extends ViewData {
|
||||||
|
success: boolean;
|
||||||
|
raceId: string;
|
||||||
|
driversProcessed: number;
|
||||||
|
resultsRecorded: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
@@ -1,39 +1,41 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { DriverRegistrationStatusViewModel } from './DriverRegistrationStatusViewModel';
|
import { DriverRegistrationStatusViewModel } from './DriverRegistrationStatusViewModel';
|
||||||
import type { DriverRegistrationStatusDTO } from '../types/generated/DriverRegistrationStatusDTO';
|
import type { DriverRegistrationStatusViewData } from '../view-data/DriverRegistrationStatusViewData';
|
||||||
|
|
||||||
const createStatusDto = (overrides: Partial<DriverRegistrationStatusDTO> = {}): DriverRegistrationStatusDTO => ({
|
const createViewData = (
|
||||||
|
overrides: Partial<DriverRegistrationStatusViewData> = {},
|
||||||
|
): DriverRegistrationStatusViewData => ({
|
||||||
isRegistered: true,
|
isRegistered: true,
|
||||||
raceId: 'race-1',
|
raceId: 'race-1',
|
||||||
driverId: 'driver-1',
|
driverId: 'driver-1',
|
||||||
|
canRegister: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DriverRegistrationStatusViewModel', () => {
|
describe('DriverRegistrationStatusViewModel', () => {
|
||||||
it('maps basic registration status fields from DTO', () => {
|
it('exposes basic registration status fields from ViewData', () => {
|
||||||
const dto = createStatusDto({ isRegistered: true });
|
const viewModel = new DriverRegistrationStatusViewModel(createViewData({ isRegistered: true }));
|
||||||
const viewModel = new DriverRegistrationStatusViewModel(dto);
|
|
||||||
|
|
||||||
expect(viewModel.isRegistered).toBe(true);
|
expect(viewModel.isRegistered).toBe(true);
|
||||||
expect(viewModel.raceId).toBe('race-1');
|
expect(viewModel.raceId).toBe('race-1');
|
||||||
expect(viewModel.driverId).toBe('driver-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);
|
expect(viewModel.canRegister).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('derives UI fields when registered', () => {
|
||||||
|
const viewModel = new DriverRegistrationStatusViewModel(createViewData({ isRegistered: true }));
|
||||||
|
|
||||||
|
expect(viewModel.statusMessage).toBe('Registered for this race');
|
||||||
|
expect(viewModel.statusBadgeVariant).toBe('success');
|
||||||
|
expect(viewModel.registrationButtonText).toBe('Withdraw');
|
||||||
|
});
|
||||||
|
|
||||||
it('derives UI fields when not registered', () => {
|
it('derives UI fields when not registered', () => {
|
||||||
const viewModel = new DriverRegistrationStatusViewModel(createStatusDto({ isRegistered: false }));
|
const viewModel = new DriverRegistrationStatusViewModel(
|
||||||
|
createViewData({ isRegistered: false, canRegister: true }),
|
||||||
|
);
|
||||||
|
|
||||||
expect(viewModel.statusMessage).toBe('Not registered');
|
expect(viewModel.statusMessage).toBe('Not registered');
|
||||||
expect(viewModel.statusColor).toBe('red');
|
|
||||||
expect(viewModel.statusBadgeVariant).toBe('warning');
|
expect(viewModel.statusBadgeVariant).toBe('warning');
|
||||||
expect(viewModel.registrationButtonText).toBe('Register');
|
expect(viewModel.registrationButtonText).toBe('Register');
|
||||||
expect(viewModel.canRegister).toBe(true);
|
expect(viewModel.canRegister).toBe(true);
|
||||||
|
|||||||
@@ -1,38 +1,37 @@
|
|||||||
import { DriverRegistrationStatusDTO } from '@/lib/types/generated/DriverRegistrationStatusDTO';
|
|
||||||
|
|
||||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||||
|
import type { DriverRegistrationStatusViewData } from "../view-data/DriverRegistrationStatusViewData";
|
||||||
|
import { DriverRegistrationStatusDisplay } from "../display-objects/DriverRegistrationStatusDisplay";
|
||||||
|
|
||||||
export class DriverRegistrationStatusViewModel extends ViewModel {
|
export class DriverRegistrationStatusViewModel extends ViewModel {
|
||||||
isRegistered!: boolean;
|
constructor(private readonly viewData: DriverRegistrationStatusViewData) {
|
||||||
raceId!: string;
|
super();
|
||||||
driverId!: string;
|
|
||||||
|
|
||||||
constructor(dto: DriverRegistrationStatusDTO) {
|
|
||||||
Object.assign(this, dto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI-specific: Status message */
|
get isRegistered(): boolean {
|
||||||
get statusMessage(): string {
|
return this.viewData.isRegistered;
|
||||||
return this.isRegistered ? 'Registered for this race' : 'Not registered';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI-specific: Status color */
|
get raceId(): string {
|
||||||
get statusColor(): string {
|
return this.viewData.raceId;
|
||||||
return this.isRegistered ? 'green' : 'red';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI-specific: Badge variant */
|
get driverId(): string {
|
||||||
get statusBadgeVariant(): string {
|
return this.viewData.driverId;
|
||||||
return this.isRegistered ? 'success' : 'warning';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI-specific: Registration button text */
|
|
||||||
get registrationButtonText(): string {
|
|
||||||
return this.isRegistered ? 'Withdraw' : 'Register';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** UI-specific: Whether can register (assuming always can if not registered) */
|
|
||||||
get canRegister(): boolean {
|
get canRegister(): boolean {
|
||||||
return !this.isRegistered;
|
return this.viewData.canRegister;
|
||||||
|
}
|
||||||
|
|
||||||
|
get statusMessage(): string {
|
||||||
|
return DriverRegistrationStatusDisplay.statusMessage(this.isRegistered);
|
||||||
|
}
|
||||||
|
|
||||||
|
get statusBadgeVariant(): string {
|
||||||
|
return DriverRegistrationStatusDisplay.statusBadgeVariant(this.isRegistered);
|
||||||
|
}
|
||||||
|
|
||||||
|
get registrationButtonText(): string {
|
||||||
|
return DriverRegistrationStatusDisplay.registrationButtonText(this.isRegistered);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,45 +1,51 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { DriverSummaryViewModel } from './DriverSummaryViewModel';
|
import { DriverSummaryViewModel } from './DriverSummaryViewModel';
|
||||||
import type { GetDriverOutputDTO } from '../types/generated/GetDriverOutputDTO';
|
import type { DriverSummaryData } from '../view-data/LeagueDetailViewData';
|
||||||
|
|
||||||
const driverDto: GetDriverOutputDTO = {
|
const viewData: DriverSummaryData = {
|
||||||
id: 'driver-1',
|
driverId: 'driver-1',
|
||||||
iracingId: 'ir-123',
|
driverName: 'Test Driver',
|
||||||
name: 'Test Driver',
|
avatarUrl: 'https://example.com/avatar.png',
|
||||||
country: 'DE',
|
rating: 2500,
|
||||||
joinedAt: '2024-01-01T00:00:00Z',
|
rank: 10,
|
||||||
|
roleBadgeText: 'Owner',
|
||||||
|
roleBadgeClasses: 'bg-blue-50 text-blue-700',
|
||||||
|
profileUrl: '/drivers/driver-1',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('DriverSummaryViewModel', () => {
|
describe('DriverSummaryViewModel', () => {
|
||||||
it('maps driver and optional fields from DTO', () => {
|
it('exposes driver identity fields from ViewData', () => {
|
||||||
const viewModel = new DriverSummaryViewModel({
|
const viewModel = new DriverSummaryViewModel(viewData);
|
||||||
driver: driverDto,
|
|
||||||
rating: 2500,
|
expect(viewModel.id).toBe('driver-1');
|
||||||
rank: 10,
|
expect(viewModel.name).toBe('Test Driver');
|
||||||
});
|
expect(viewModel.avatarUrl).toBe('https://example.com/avatar.png');
|
||||||
|
expect(viewModel.profileUrl).toBe('/drivers/driver-1');
|
||||||
|
expect(viewModel.roleBadgeText).toBe('Owner');
|
||||||
|
expect(viewModel.roleBadgeClasses).toBe('bg-blue-50 text-blue-700');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives ratingLabel and rankLabel for UI rendering', () => {
|
||||||
|
const viewModel = new DriverSummaryViewModel(viewData);
|
||||||
|
|
||||||
expect(viewModel.driver).toBe(driverDto);
|
|
||||||
expect(viewModel.rating).toBe(2500);
|
expect(viewModel.rating).toBe(2500);
|
||||||
|
expect(viewModel.ratingLabel).toBe('2,500');
|
||||||
|
|
||||||
expect(viewModel.rank).toBe(10);
|
expect(viewModel.rank).toBe(10);
|
||||||
|
expect(viewModel.rankLabel).toBe('10');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defaults nullable rating and rank when undefined', () => {
|
it('renders placeholders when rating or rank are null', () => {
|
||||||
const viewModel = new DriverSummaryViewModel({
|
const viewModel = new DriverSummaryViewModel({
|
||||||
driver: driverDto,
|
...viewData,
|
||||||
});
|
|
||||||
|
|
||||||
expect(viewModel.rating).toBeNull();
|
|
||||||
expect(viewModel.rank).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps explicit null rating and rank', () => {
|
|
||||||
const viewModel = new DriverSummaryViewModel({
|
|
||||||
driver: driverDto,
|
|
||||||
rating: null,
|
rating: null,
|
||||||
rank: null,
|
rank: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(viewModel.rating).toBeNull();
|
expect(viewModel.rating).toBeNull();
|
||||||
|
expect(viewModel.ratingLabel).toBe('—');
|
||||||
|
|
||||||
expect(viewModel.rank).toBeNull();
|
expect(viewModel.rank).toBeNull();
|
||||||
|
expect(viewModel.rankLabel).toBe('—');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,55 @@
|
|||||||
import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO';
|
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||||
|
import type { DriverSummaryData } from "../view-data/LeagueDetailViewData";
|
||||||
|
import { NumberDisplay } from "../display-objects/NumberDisplay";
|
||||||
|
import { RatingDisplay } from "../display-objects/RatingDisplay";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View Model for driver summary with rating and rank
|
* View Model for driver summary with rating and rank
|
||||||
* Transform from DTO to ViewModel with UI fields
|
*
|
||||||
|
* Client-only UI helper built from ViewData.
|
||||||
*/
|
*/
|
||||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
|
||||||
|
|
||||||
export class DriverSummaryViewModel extends ViewModel {
|
export class DriverSummaryViewModel extends ViewModel {
|
||||||
driver: GetDriverOutputDTO;
|
constructor(private readonly viewData: DriverSummaryData) {
|
||||||
rating: number | null;
|
super();
|
||||||
rank: number | null;
|
}
|
||||||
|
|
||||||
constructor(dto: {
|
get id(): string {
|
||||||
driver: GetDriverOutputDTO;
|
return this.viewData.driverId;
|
||||||
rating?: number | null;
|
}
|
||||||
rank?: number | null;
|
|
||||||
}) {
|
get name(): string {
|
||||||
this.driver = dto.driver;
|
return this.viewData.driverName;
|
||||||
this.rating = dto.rating ?? null;
|
}
|
||||||
this.rank = dto.rank ?? null;
|
|
||||||
|
get avatarUrl(): string | null {
|
||||||
|
return this.viewData.avatarUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
get rating(): number | null {
|
||||||
|
return this.viewData.rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
get ratingLabel(): string {
|
||||||
|
return RatingDisplay.format(this.rating);
|
||||||
|
}
|
||||||
|
|
||||||
|
get rank(): number | null {
|
||||||
|
return this.viewData.rank;
|
||||||
|
}
|
||||||
|
|
||||||
|
get rankLabel(): string {
|
||||||
|
return this.rank === null ? '—' : NumberDisplay.format(this.rank);
|
||||||
|
}
|
||||||
|
|
||||||
|
get roleBadgeText(): string {
|
||||||
|
return this.viewData.roleBadgeText;
|
||||||
|
}
|
||||||
|
|
||||||
|
get roleBadgeClasses(): string {
|
||||||
|
return this.viewData.roleBadgeClasses;
|
||||||
|
}
|
||||||
|
|
||||||
|
get profileUrl(): string {
|
||||||
|
return this.viewData.profileUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,68 +1,59 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from "vitest";
|
||||||
import { DriverTeamViewModel } from './DriverTeamViewModel';
|
import { DriverTeamViewModel } from "./DriverTeamViewModel";
|
||||||
import type { GetDriverTeamOutputDTO } from '@/lib/types/generated/GetDriverTeamOutputDTO';
|
import type { TeamDetailData } from "../view-data/TeamDetailViewData";
|
||||||
|
|
||||||
const createTeamDto = (overrides: Partial<GetDriverTeamOutputDTO> = {}): GetDriverTeamOutputDTO => ({
|
const createTeamViewData = (overrides: Partial<TeamDetailData> = {}): TeamDetailData => ({
|
||||||
team: {
|
id: "team-1",
|
||||||
id: 'team-1',
|
name: "Test Team",
|
||||||
name: 'Test Team',
|
tag: "TT",
|
||||||
tag: 'TT',
|
ownerId: "owner-1",
|
||||||
description: 'Test team description',
|
leagues: ["league-1"],
|
||||||
ownerId: 'owner-1',
|
canManage: true,
|
||||||
leagues: ['league-1'],
|
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
|
||||||
specialization: 'mixed',
|
|
||||||
region: 'EU',
|
|
||||||
languages: ['en'],
|
|
||||||
},
|
|
||||||
membership: {
|
membership: {
|
||||||
role: 'manager',
|
role: "manager",
|
||||||
joinedAt: '2024-01-01T00:00:00Z',
|
joinedAt: "2024-01-01T00:00:00Z",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
isOwner: false,
|
|
||||||
canManage: true,
|
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DriverTeamViewModel', () => {
|
describe("DriverTeamViewModel", () => {
|
||||||
it('maps team and membership fields from DTO', () => {
|
it("exposes team fields from ViewData", () => {
|
||||||
const dto = createTeamDto();
|
const viewData = createTeamViewData();
|
||||||
const viewModel = new DriverTeamViewModel(dto);
|
const viewModel = new DriverTeamViewModel(viewData);
|
||||||
|
|
||||||
expect(viewModel.teamId).toBe('team-1');
|
expect(viewModel.teamId).toBe("team-1");
|
||||||
expect(viewModel.teamName).toBe('Test Team');
|
expect(viewModel.teamName).toBe("Test Team");
|
||||||
expect(viewModel.tag).toBe('TT');
|
expect(viewModel.tag).toBe("TT");
|
||||||
expect(viewModel.role).toBe('manager');
|
expect(viewModel.role).toBe("manager");
|
||||||
expect(viewModel.isOwner).toBe(false);
|
expect(viewModel.isOwner).toBe(false);
|
||||||
expect(viewModel.canManage).toBe(true);
|
expect(viewModel.canManage).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('derives displayRole with capitalized first letter', () => {
|
it("derives displayRole with capitalized first letter", () => {
|
||||||
const dto = createTeamDto({
|
const viewData = createTeamViewData({
|
||||||
membership: {
|
membership: {
|
||||||
role: 'owner',
|
role: "owner",
|
||||||
joinedAt: '2024-01-01T00:00:00Z',
|
joinedAt: "2024-01-01T00:00:00Z",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewModel = new DriverTeamViewModel(dto);
|
const viewModel = new DriverTeamViewModel(viewData);
|
||||||
|
|
||||||
expect(viewModel.displayRole).toBe('Owner');
|
expect(viewModel.displayRole).toBe("Owner");
|
||||||
|
expect(viewModel.isOwner).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles lower-case role strings consistently', () => {
|
it("defaults role when membership is missing", () => {
|
||||||
const dto = createTeamDto({
|
const viewData = createTeamViewData({
|
||||||
membership: {
|
membership: null,
|
||||||
role: 'member',
|
|
||||||
joinedAt: '2024-01-01T00:00:00Z',
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewModel = new DriverTeamViewModel(dto);
|
const viewModel = new DriverTeamViewModel(viewData);
|
||||||
|
|
||||||
expect(viewModel.displayRole).toBe('Member');
|
expect(viewModel.role).toBe("member");
|
||||||
|
expect(viewModel.displayRole).toBe("Member");
|
||||||
|
expect(viewModel.isOwner).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,31 +1,44 @@
|
|||||||
import type { GetDriverTeamOutputDTO } from '@/lib/types/generated/GetDriverTeamOutputDTO';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View Model for Driver's Team
|
* View Model for Driver's Team
|
||||||
*
|
*
|
||||||
* Represents a driver's team membership in a UI-ready format.
|
* Client-only UI helper built from ViewData.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { ProfileDisplay } from "../display-objects/ProfileDisplay";
|
||||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||||
|
import type { TeamDetailData } from "../view-data/TeamDetailViewData";
|
||||||
|
|
||||||
export class DriverTeamViewModel extends ViewModel {
|
export class DriverTeamViewModel extends ViewModel {
|
||||||
teamId: string;
|
constructor(private readonly viewData: TeamDetailData) {
|
||||||
teamName: string;
|
super();
|
||||||
tag: string;
|
}
|
||||||
role: string;
|
|
||||||
isOwner: boolean;
|
|
||||||
canManage: boolean;
|
|
||||||
|
|
||||||
constructor(dto: GetDriverTeamOutputDTO) {
|
get teamId(): string {
|
||||||
this.teamId = dto.team.id;
|
return this.viewData.id;
|
||||||
this.teamName = dto.team.name;
|
}
|
||||||
this.tag = dto.team.tag;
|
|
||||||
this.role = dto.membership.role;
|
get teamName(): string {
|
||||||
this.isOwner = dto.isOwner;
|
return this.viewData.name;
|
||||||
this.canManage = dto.canManage;
|
}
|
||||||
|
|
||||||
|
get tag(): string {
|
||||||
|
return this.viewData.tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
get role(): string {
|
||||||
|
return this.viewData.membership?.role ?? "member";
|
||||||
|
}
|
||||||
|
|
||||||
|
get canManage(): boolean {
|
||||||
|
return this.viewData.canManage;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isOwner(): boolean {
|
||||||
|
return this.viewData.membership?.role === "owner";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI-specific: Display role */
|
/** UI-specific: Display role */
|
||||||
get displayRole(): string {
|
get displayRole(): string {
|
||||||
return this.role.charAt(0).toUpperCase() + this.role.slice(1);
|
return ProfileDisplay.getTeamRole(this.role).text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,111 +1,139 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from "vitest";
|
||||||
import { DriverViewModel } from './DriverViewModel';
|
import { DriverViewModel } from "./DriverViewModel";
|
||||||
|
import type { DriverData } from "../view-data/LeagueStandingsViewData";
|
||||||
|
|
||||||
describe('DriverViewModel', () => {
|
describe("DriverViewModel", () => {
|
||||||
it('should create instance with all properties', () => {
|
it("should create instance with all properties", () => {
|
||||||
const dto = {
|
const viewData: DriverData = {
|
||||||
id: 'driver-123',
|
id: "driver-123",
|
||||||
name: 'John Doe',
|
name: "John Doe",
|
||||||
avatarUrl: 'https://example.com/avatar.jpg',
|
avatarUrl: "https://example.com/avatar.jpg",
|
||||||
iracingId: 'iracing-456',
|
iracingId: "iracing-456",
|
||||||
rating: 1500,
|
rating: 1500,
|
||||||
|
country: "US",
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewModel = new DriverViewModel(dto);
|
const viewModel = new DriverViewModel(viewData);
|
||||||
|
|
||||||
expect(viewModel.id).toBe('driver-123');
|
expect(viewModel.id).toBe("driver-123");
|
||||||
expect(viewModel.name).toBe('John Doe');
|
expect(viewModel.name).toBe("John Doe");
|
||||||
expect(viewModel.avatarUrl).toBe('https://example.com/avatar.jpg');
|
expect(viewModel.avatarUrl).toBe("https://example.com/avatar.jpg");
|
||||||
expect(viewModel.iracingId).toBe('iracing-456');
|
expect(viewModel.iracingId).toBe("iracing-456");
|
||||||
expect(viewModel.rating).toBe(1500);
|
expect(viewModel.rating).toBe(1500);
|
||||||
|
expect(viewModel.country).toBe("US");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create instance with only required properties', () => {
|
it("should create instance with only required properties", () => {
|
||||||
const dto = {
|
const viewData: DriverData = {
|
||||||
id: 'driver-123',
|
id: "driver-123",
|
||||||
name: 'John Doe',
|
name: "John Doe",
|
||||||
|
avatarUrl: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewModel = new DriverViewModel(dto);
|
const viewModel = new DriverViewModel(viewData);
|
||||||
|
|
||||||
expect(viewModel.id).toBe('driver-123');
|
expect(viewModel.id).toBe("driver-123");
|
||||||
expect(viewModel.name).toBe('John Doe');
|
expect(viewModel.name).toBe("John Doe");
|
||||||
expect(viewModel.avatarUrl).toBeNull();
|
expect(viewModel.avatarUrl).toBeNull();
|
||||||
expect(viewModel.iracingId).toBeUndefined();
|
expect(viewModel.iracingId).toBeUndefined();
|
||||||
expect(viewModel.rating).toBeUndefined();
|
expect(viewModel.rating).toBeUndefined();
|
||||||
|
expect(viewModel.country).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for hasIracingId when iracingId exists', () => {
|
it("should return true for hasIracingId when iracingId exists", () => {
|
||||||
const viewModel = new DriverViewModel({
|
const viewData: DriverData = {
|
||||||
id: 'driver-123',
|
id: "driver-123",
|
||||||
name: 'John Doe',
|
name: "John Doe",
|
||||||
iracingId: 'iracing-456',
|
avatarUrl: null,
|
||||||
});
|
iracingId: "iracing-456",
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewModel = new DriverViewModel(viewData);
|
||||||
|
|
||||||
expect(viewModel.hasIracingId).toBe(true);
|
expect(viewModel.hasIracingId).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for hasIracingId when iracingId is undefined', () => {
|
it("should return false for hasIracingId when iracingId is undefined", () => {
|
||||||
const viewModel = new DriverViewModel({
|
const viewData: DriverData = {
|
||||||
id: 'driver-123',
|
id: "driver-123",
|
||||||
name: 'John Doe',
|
name: "John Doe",
|
||||||
});
|
avatarUrl: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewModel = new DriverViewModel(viewData);
|
||||||
|
|
||||||
expect(viewModel.hasIracingId).toBe(false);
|
expect(viewModel.hasIracingId).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for hasIracingId when iracingId is empty string', () => {
|
it("should return false for hasIracingId when iracingId is empty string", () => {
|
||||||
const viewModel = new DriverViewModel({
|
const viewData: DriverData = {
|
||||||
id: 'driver-123',
|
id: "driver-123",
|
||||||
name: 'John Doe',
|
name: "John Doe",
|
||||||
iracingId: '',
|
avatarUrl: null,
|
||||||
});
|
iracingId: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewModel = new DriverViewModel(viewData);
|
||||||
|
|
||||||
expect(viewModel.hasIracingId).toBe(false);
|
expect(viewModel.hasIracingId).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format rating correctly when rating exists', () => {
|
it("should format rating correctly when rating exists", () => {
|
||||||
const viewModel = new DriverViewModel({
|
const viewData: DriverData = {
|
||||||
id: 'driver-123',
|
id: "driver-123",
|
||||||
name: 'John Doe',
|
name: "John Doe",
|
||||||
|
avatarUrl: null,
|
||||||
rating: 1547.89,
|
rating: 1547.89,
|
||||||
});
|
};
|
||||||
|
|
||||||
expect(viewModel.formattedRating).toBe('1548');
|
const viewModel = new DriverViewModel(viewData);
|
||||||
|
|
||||||
|
expect(viewModel.formattedRating).toBe("1548");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return "Unrated" when rating is undefined', () => {
|
it("should return \"Unrated\" when rating is undefined", () => {
|
||||||
const viewModel = new DriverViewModel({
|
const viewData: DriverData = {
|
||||||
id: 'driver-123',
|
id: "driver-123",
|
||||||
name: 'John Doe',
|
name: "John Doe",
|
||||||
});
|
avatarUrl: null,
|
||||||
|
};
|
||||||
|
|
||||||
expect(viewModel.formattedRating).toBe('Unrated');
|
const viewModel = new DriverViewModel(viewData);
|
||||||
|
|
||||||
|
expect(viewModel.formattedRating).toBe("Unrated");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle zero rating', () => {
|
it("should handle zero rating", () => {
|
||||||
const viewModel = new DriverViewModel({
|
const viewData: DriverData = {
|
||||||
id: 'driver-123',
|
id: "driver-123",
|
||||||
name: 'John Doe',
|
name: "John Doe",
|
||||||
|
avatarUrl: null,
|
||||||
rating: 0,
|
rating: 0,
|
||||||
});
|
};
|
||||||
|
|
||||||
expect(viewModel.formattedRating).toBe('Unrated');
|
const viewModel = new DriverViewModel(viewData);
|
||||||
|
|
||||||
|
expect(viewModel.formattedRating).toBe("Unrated");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should round rating to nearest integer', () => {
|
it("should round rating to nearest integer", () => {
|
||||||
const viewModel1 = new DriverViewModel({
|
const viewData1: DriverData = {
|
||||||
id: 'driver-123',
|
id: "driver-123",
|
||||||
name: 'John Doe',
|
name: "John Doe",
|
||||||
|
avatarUrl: null,
|
||||||
rating: 1500.4,
|
rating: 1500.4,
|
||||||
});
|
};
|
||||||
const viewModel2 = new DriverViewModel({
|
const viewData2: DriverData = {
|
||||||
id: 'driver-123',
|
id: "driver-123",
|
||||||
name: 'John Doe',
|
name: "John Doe",
|
||||||
|
avatarUrl: null,
|
||||||
rating: 1500.6,
|
rating: 1500.6,
|
||||||
});
|
};
|
||||||
|
|
||||||
expect(viewModel1.formattedRating).toBe('1500');
|
const viewModel1 = new DriverViewModel(viewData1);
|
||||||
expect(viewModel2.formattedRating).toBe('1501');
|
const viewModel2 = new DriverViewModel(viewData2);
|
||||||
|
|
||||||
|
expect(viewModel1.formattedRating).toBe("1500");
|
||||||
|
expect(viewModel2.formattedRating).toBe("1501");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,9 +2,15 @@
|
|||||||
* Driver view model
|
* Driver view model
|
||||||
* UI representation of a driver
|
* UI representation of a driver
|
||||||
*
|
*
|
||||||
* Note: No matching generated DTO available yet
|
* Note: client-only ViewModel created from ViewData (never DTO).
|
||||||
*/
|
*/
|
||||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||||
|
import type { DriverData } from "../view-data/LeagueStandingsViewData";
|
||||||
|
|
||||||
|
type DriverViewModelViewData = DriverData & {
|
||||||
|
bio?: string;
|
||||||
|
joinedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export class DriverViewModel extends ViewModel {
|
export class DriverViewModel extends ViewModel {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,25 +22,16 @@ export class DriverViewModel extends ViewModel {
|
|||||||
bio?: string;
|
bio?: string;
|
||||||
joinedAt?: string;
|
joinedAt?: string;
|
||||||
|
|
||||||
constructor(dto: {
|
constructor(viewData: DriverViewModelViewData) {
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
avatarUrl?: string | null;
|
|
||||||
iracingId?: string;
|
|
||||||
rating?: number;
|
|
||||||
country?: string;
|
|
||||||
bio?: string;
|
|
||||||
joinedAt?: string;
|
|
||||||
}) {
|
|
||||||
super();
|
super();
|
||||||
this.id = dto.id;
|
this.id = viewData.id;
|
||||||
this.name = dto.name;
|
this.name = viewData.name;
|
||||||
this.avatarUrl = dto.avatarUrl ?? null;
|
this.avatarUrl = viewData.avatarUrl ?? null;
|
||||||
if (dto.iracingId !== undefined) this.iracingId = dto.iracingId;
|
if (viewData.iracingId !== undefined) this.iracingId = viewData.iracingId;
|
||||||
if (dto.rating !== undefined) this.rating = dto.rating;
|
if (viewData.rating !== undefined) this.rating = viewData.rating;
|
||||||
if (dto.country !== undefined) this.country = dto.country;
|
if (viewData.country !== undefined) this.country = viewData.country;
|
||||||
if (dto.bio !== undefined) this.bio = dto.bio;
|
if (viewData.bio !== undefined) this.bio = viewData.bio;
|
||||||
if (dto.joinedAt !== undefined) this.joinedAt = dto.joinedAt;
|
if (viewData.joinedAt !== undefined) this.joinedAt = viewData.joinedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI-specific: Whether driver has an iRacing ID */
|
/** UI-specific: Whether driver has an iRacing ID */
|
||||||
@@ -44,6 +41,6 @@ export class DriverViewModel extends ViewModel {
|
|||||||
|
|
||||||
/** UI-specific: Formatted rating */
|
/** UI-specific: Formatted rating */
|
||||||
get formattedRating(): string {
|
get formattedRating(): string {
|
||||||
return this.rating ? this.rating.toFixed(0) : 'Unrated';
|
return this.rating ? this.rating.toFixed(0) : "Unrated";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,37 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { EmailSignupViewModel } from './EmailSignupViewModel';
|
import { EmailSignupViewModel } from './EmailSignupViewModel';
|
||||||
|
import type { EmailSignupViewData } from '../view-data/EmailSignupViewData';
|
||||||
|
|
||||||
describe('EmailSignupViewModel', () => {
|
describe('EmailSignupViewModel', () => {
|
||||||
it('should be defined', () => {
|
it('wraps EmailSignupViewData and exposes UI helpers', () => {
|
||||||
expect(EmailSignupViewModel).toBeDefined();
|
const viewData: EmailSignupViewData = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
message: 'Thanks for signing up!',
|
||||||
|
status: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewModel = new EmailSignupViewModel(viewData);
|
||||||
|
|
||||||
|
expect(viewModel.email).toBe('test@example.com');
|
||||||
|
expect(viewModel.message).toBe('Thanks for signing up!');
|
||||||
|
expect(viewModel.status).toBe('success');
|
||||||
|
|
||||||
|
expect(viewModel.isSuccess).toBe(true);
|
||||||
|
expect(viewModel.isError).toBe(false);
|
||||||
|
expect(viewModel.isInfo).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects error status helpers', () => {
|
||||||
|
const viewData: EmailSignupViewData = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
message: 'Something went wrong',
|
||||||
|
status: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewModel = new EmailSignupViewModel(viewData);
|
||||||
|
|
||||||
|
expect(viewModel.isSuccess).toBe(false);
|
||||||
|
expect(viewModel.isError).toBe(true);
|
||||||
|
expect(viewModel.isInfo).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,15 +4,34 @@
|
|||||||
* View model for email signup responses
|
* View model for email signup responses
|
||||||
*/
|
*/
|
||||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||||
|
import type { EmailSignupViewData } from "../view-data/EmailSignupViewData";
|
||||||
|
|
||||||
export class EmailSignupViewModel extends ViewModel {
|
export class EmailSignupViewModel extends ViewModel {
|
||||||
readonly email: string;
|
constructor(private readonly viewData: EmailSignupViewData) {
|
||||||
readonly message: string;
|
super();
|
||||||
readonly status: 'success' | 'error' | 'info';
|
}
|
||||||
|
|
||||||
constructor(email: string, message: string, status: 'success' | 'error' | 'info') {
|
get email(): string {
|
||||||
this.email = email;
|
return this.viewData.email;
|
||||||
this.message = message;
|
}
|
||||||
this.status = status;
|
|
||||||
|
get message(): string {
|
||||||
|
return this.viewData.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
get status(): EmailSignupViewData["status"] {
|
||||||
|
return this.viewData.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSuccess(): boolean {
|
||||||
|
return this.status === 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isError(): boolean {
|
||||||
|
return this.status === 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isInfo(): boolean {
|
||||||
|
return this.status === 'info';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,48 +1,35 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { HomeDiscoveryViewModel } from './HomeDiscoveryViewModel';
|
import { HomeDiscoveryViewModel } from './HomeDiscoveryViewModel';
|
||||||
import { LeagueCardViewModel } from './LeagueCardViewModel';
|
import type { HomeDiscoveryViewData } from '@/lib/view-data/HomeDiscoveryViewData';
|
||||||
import { TeamCardViewModel } from './TeamCardViewModel';
|
|
||||||
import { UpcomingRaceCardViewModel } from './UpcomingRaceCardViewModel';
|
|
||||||
|
|
||||||
describe('HomeDiscoveryViewModel', () => {
|
describe('HomeDiscoveryViewModel', () => {
|
||||||
it('exposes the top leagues, teams and upcoming races from the DTO', () => {
|
it('exposes the discovery collections from ViewData', () => {
|
||||||
const topLeagues = [
|
const viewData: HomeDiscoveryViewData = {
|
||||||
new LeagueCardViewModel({
|
topLeagues: [{ id: 'league-1', name: 'Pro League', description: 'Top-tier league' }],
|
||||||
id: 'league-1',
|
teams: [{ id: 'team-1', name: 'Team Alpha', description: 'Serious endurance', logoUrl: undefined }],
|
||||||
name: 'Pro League',
|
upcomingRaces: [
|
||||||
description: 'Top-tier league',
|
{ id: 'race-1', track: 'Spa-Francorchamps', car: 'GT3', formattedDate: 'Jan 1' },
|
||||||
memberCount: 100,
|
],
|
||||||
isMember: false,
|
};
|
||||||
} as any),
|
|
||||||
];
|
|
||||||
|
|
||||||
const teams = [
|
const viewModel = new HomeDiscoveryViewModel(viewData);
|
||||||
new TeamCardViewModel({
|
|
||||||
id: 'team-1',
|
|
||||||
name: 'Team Alpha',
|
|
||||||
tag: 'ALPHA',
|
|
||||||
memberCount: 10,
|
|
||||||
isMember: true,
|
|
||||||
} as any),
|
|
||||||
];
|
|
||||||
|
|
||||||
const upcomingRaces = [
|
expect(viewModel.topLeagues).toBe(viewData.topLeagues);
|
||||||
new UpcomingRaceCardViewModel({
|
expect(viewModel.teams).toBe(viewData.teams);
|
||||||
id: 'race-1',
|
expect(viewModel.upcomingRaces).toBe(viewData.upcomingRaces);
|
||||||
track: 'Spa-Francorchamps',
|
});
|
||||||
car: 'GT3',
|
|
||||||
scheduledAt: '2025-01-01T12:00:00Z',
|
|
||||||
} as any),
|
|
||||||
];
|
|
||||||
|
|
||||||
const viewModel = new HomeDiscoveryViewModel({
|
it('provides basic UI helper booleans', () => {
|
||||||
topLeagues,
|
const viewData: HomeDiscoveryViewData = {
|
||||||
teams,
|
topLeagues: [],
|
||||||
upcomingRaces,
|
teams: [{ id: 'team-1', name: 'Team Alpha', description: 'Serious endurance' }],
|
||||||
});
|
upcomingRaces: [],
|
||||||
|
};
|
||||||
|
|
||||||
expect(viewModel.topLeagues).toBe(topLeagues);
|
const viewModel = new HomeDiscoveryViewModel(viewData);
|
||||||
expect(viewModel.teams).toBe(teams);
|
|
||||||
expect(viewModel.upcomingRaces).toBe(upcomingRaces);
|
expect(viewModel.hasTopLeagues).toBe(false);
|
||||||
|
expect(viewModel.hasTeams).toBe(true);
|
||||||
|
expect(viewModel.hasUpcomingRaces).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,27 +1,32 @@
|
|||||||
import { LeagueCardViewModel } from './LeagueCardViewModel';
|
import type { HomeDiscoveryViewData } from '@/lib/view-data/HomeDiscoveryViewData';
|
||||||
import { TeamCardViewModel } from './TeamCardViewModel';
|
|
||||||
import { UpcomingRaceCardViewModel } from './UpcomingRaceCardViewModel';
|
|
||||||
|
|
||||||
interface HomeDiscoveryDTO {
|
|
||||||
topLeagues: LeagueCardViewModel[];
|
|
||||||
teams: TeamCardViewModel[];
|
|
||||||
upcomingRaces: UpcomingRaceCardViewModel[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Home discovery view model
|
* Home discovery view model
|
||||||
* Aggregates discovery data for the landing page.
|
* Aggregates discovery data for the landing page.
|
||||||
*/
|
*/
|
||||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
import { ViewModel } from '../contracts/view-models/ViewModel';
|
||||||
|
|
||||||
export class HomeDiscoveryViewModel extends ViewModel {
|
export class HomeDiscoveryViewModel extends ViewModel {
|
||||||
readonly topLeagues: LeagueCardViewModel[];
|
readonly topLeagues: HomeDiscoveryViewData['topLeagues'];
|
||||||
readonly teams: TeamCardViewModel[];
|
readonly teams: HomeDiscoveryViewData['teams'];
|
||||||
readonly upcomingRaces: UpcomingRaceCardViewModel[];
|
readonly upcomingRaces: HomeDiscoveryViewData['upcomingRaces'];
|
||||||
|
|
||||||
constructor(dto: HomeDiscoveryDTO) {
|
constructor(viewData: HomeDiscoveryViewData) {
|
||||||
this.topLeagues = dto.topLeagues;
|
super();
|
||||||
this.teams = dto.teams;
|
this.topLeagues = viewData.topLeagues;
|
||||||
this.upcomingRaces = dto.upcomingRaces;
|
this.teams = viewData.teams;
|
||||||
|
this.upcomingRaces = viewData.upcomingRaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasTopLeagues(): boolean {
|
||||||
|
return this.topLeagues.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasTeams(): boolean {
|
||||||
|
return this.teams.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasUpcomingRaces(): boolean {
|
||||||
|
return this.upcomingRaces.length > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { ImportRaceResultsSummaryViewData } from '../view-data/ImportRaceResultsSummaryViewData';
|
||||||
import { ImportRaceResultsSummaryViewModel } from './ImportRaceResultsSummaryViewModel';
|
import { ImportRaceResultsSummaryViewModel } from './ImportRaceResultsSummaryViewModel';
|
||||||
|
|
||||||
describe('ImportRaceResultsSummaryViewModel', () => {
|
describe('ImportRaceResultsSummaryViewModel', () => {
|
||||||
it('maps DTO fields including errors', () => {
|
it('exposes view data fields', () => {
|
||||||
const dto = {
|
const viewData: ImportRaceResultsSummaryViewData = {
|
||||||
success: true,
|
success: true,
|
||||||
raceId: 'race-1',
|
raceId: 'race-1',
|
||||||
driversProcessed: 10,
|
driversProcessed: 10,
|
||||||
@@ -11,7 +12,7 @@ describe('ImportRaceResultsSummaryViewModel', () => {
|
|||||||
errors: ['Driver missing', 'Invalid lap time'],
|
errors: ['Driver missing', 'Invalid lap time'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewModel = new ImportRaceResultsSummaryViewModel(dto);
|
const viewModel = new ImportRaceResultsSummaryViewModel(viewData);
|
||||||
|
|
||||||
expect(viewModel.success).toBe(true);
|
expect(viewModel.success).toBe(true);
|
||||||
expect(viewModel.raceId).toBe('race-1');
|
expect(viewModel.raceId).toBe('race-1');
|
||||||
@@ -20,16 +21,24 @@ describe('ImportRaceResultsSummaryViewModel', () => {
|
|||||||
expect(viewModel.errors).toEqual(['Driver missing', 'Invalid lap time']);
|
expect(viewModel.errors).toEqual(['Driver missing', 'Invalid lap time']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defaults errors to an empty array when not provided', () => {
|
it('derives hasErrors UI helper', () => {
|
||||||
const dto = {
|
const viewDataWithErrors: ImportRaceResultsSummaryViewData = {
|
||||||
success: false,
|
success: false,
|
||||||
raceId: 'race-2',
|
raceId: 'race-2',
|
||||||
driversProcessed: 0,
|
driversProcessed: 0,
|
||||||
resultsRecorded: 0,
|
resultsRecorded: 0,
|
||||||
|
errors: ['Some error'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewModel = new ImportRaceResultsSummaryViewModel(dto);
|
const viewDataWithoutErrors: ImportRaceResultsSummaryViewData = {
|
||||||
|
success: false,
|
||||||
|
raceId: 'race-3',
|
||||||
|
driversProcessed: 0,
|
||||||
|
resultsRecorded: 0,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
expect(viewModel.errors).toEqual([]);
|
expect(new ImportRaceResultsSummaryViewModel(viewDataWithErrors).hasErrors).toBe(true);
|
||||||
|
expect(new ImportRaceResultsSummaryViewModel(viewDataWithoutErrors).hasErrors).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
interface ImportRaceResultsSummaryDTO {
|
|
||||||
success: boolean;
|
|
||||||
raceId: string;
|
|
||||||
driversProcessed: number;
|
|
||||||
resultsRecorded: number;
|
|
||||||
errors?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||||
|
import type { ImportRaceResultsSummaryViewData } from "../view-data/ImportRaceResultsSummaryViewData";
|
||||||
|
|
||||||
export class ImportRaceResultsSummaryViewModel extends ViewModel {
|
export class ImportRaceResultsSummaryViewModel extends ViewModel {
|
||||||
success: boolean;
|
constructor(private readonly viewData: ImportRaceResultsSummaryViewData) {
|
||||||
raceId: string;
|
super();
|
||||||
driversProcessed: number;
|
}
|
||||||
resultsRecorded: number;
|
|
||||||
errors: string[];
|
|
||||||
|
|
||||||
constructor(dto: ImportRaceResultsSummaryDTO) {
|
get success(): boolean {
|
||||||
this.success = dto.success;
|
return this.viewData.success;
|
||||||
this.raceId = dto.raceId;
|
}
|
||||||
this.driversProcessed = dto.driversProcessed;
|
|
||||||
this.resultsRecorded = dto.resultsRecorded;
|
get raceId(): string {
|
||||||
this.errors = dto.errors || [];
|
return this.viewData.raceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get driversProcessed(): number {
|
||||||
|
return this.viewData.driversProcessed;
|
||||||
|
}
|
||||||
|
|
||||||
|
get resultsRecorded(): number {
|
||||||
|
return this.viewData.resultsRecorded;
|
||||||
|
}
|
||||||
|
|
||||||
|
get errors(): string[] {
|
||||||
|
return this.viewData.errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasErrors(): boolean {
|
||||||
|
return this.errors.length > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,138 +1,79 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { MediaViewModel } from './MediaViewModel';
|
import { MediaViewModel } from './MediaViewModel';
|
||||||
|
import type { MediaViewData } from '@/lib/view-data/MediaViewData';
|
||||||
|
|
||||||
describe('MediaViewModel', () => {
|
describe('MediaViewModel', () => {
|
||||||
it('should create instance with all properties', () => {
|
it('creates instance from asset ViewData', () => {
|
||||||
const dto = {
|
const asset: MediaViewData['assets'][number] = {
|
||||||
id: 'media-123',
|
id: 'media-123',
|
||||||
url: 'https://example.com/image.jpg',
|
src: 'https://example.com/image.jpg',
|
||||||
type: 'image',
|
title: 'Race Day Photo',
|
||||||
category: 'avatar',
|
category: 'avatar',
|
||||||
uploadedAt: '2023-01-15T00:00:00.000Z',
|
date: '2023-01-15',
|
||||||
size: 2048000,
|
dimensions: '1920×1080',
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewModel = new MediaViewModel(dto);
|
const viewModel = new MediaViewModel(asset);
|
||||||
|
|
||||||
expect(viewModel.id).toBe('media-123');
|
expect(viewModel.id).toBe('media-123');
|
||||||
expect(viewModel.url).toBe('https://example.com/image.jpg');
|
expect(viewModel.src).toBe('https://example.com/image.jpg');
|
||||||
expect(viewModel.type).toBe('image');
|
expect(viewModel.title).toBe('Race Day Photo');
|
||||||
expect(viewModel.category).toBe('avatar');
|
expect(viewModel.category).toBe('avatar');
|
||||||
expect(viewModel.uploadedAt).toEqual(new Date('2023-01-15'));
|
expect(viewModel.date).toBe('2023-01-15');
|
||||||
expect(viewModel.size).toBe(2048000);
|
expect(viewModel.dimensions).toBe('1920×1080');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create instance without optional properties', () => {
|
it('subtitle matches MediaCard subtitle formatting', () => {
|
||||||
const dto = {
|
const asset: MediaViewData['assets'][number] = {
|
||||||
id: 'media-123',
|
id: 'media-123',
|
||||||
url: 'https://example.com/image.jpg',
|
src: 'https://example.com/image.jpg',
|
||||||
type: 'image',
|
title: 'Race Day Photo',
|
||||||
uploadedAt: '2023-01-15T00:00:00.000Z',
|
category: 'avatar',
|
||||||
|
dimensions: '1920×1080',
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewModel = new MediaViewModel(dto);
|
const viewModel = new MediaViewModel(asset);
|
||||||
|
|
||||||
expect(viewModel.category).toBeUndefined();
|
expect(viewModel.subtitle).toBe('avatar • 1920×1080');
|
||||||
expect(viewModel.size).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return "Unknown" for formattedSize when size is undefined', () => {
|
it('subtitle omits dimensions when missing', () => {
|
||||||
const viewModel = new MediaViewModel({
|
const asset: MediaViewData['assets'][number] = {
|
||||||
id: 'media-123',
|
id: 'media-123',
|
||||||
url: 'https://example.com/image.jpg',
|
src: 'https://example.com/image.jpg',
|
||||||
type: 'image',
|
title: 'Race Day Photo',
|
||||||
uploadedAt: new Date().toISOString(),
|
category: 'avatar',
|
||||||
});
|
};
|
||||||
|
|
||||||
expect(viewModel.formattedSize).toBe('Unknown');
|
const viewModel = new MediaViewModel(asset);
|
||||||
|
|
||||||
|
expect(viewModel.subtitle).toBe('avatar');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format size in KB when less than 1 MB', () => {
|
it('hasMetadata reflects presence of date or dimensions', () => {
|
||||||
const viewModel = new MediaViewModel({
|
const noMeta = new MediaViewModel({
|
||||||
id: 'media-123',
|
|
||||||
url: 'https://example.com/image.jpg',
|
|
||||||
type: 'image',
|
|
||||||
uploadedAt: new Date().toISOString(),
|
|
||||||
size: 512000, // 500 KB
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(viewModel.formattedSize).toBe('500.00 KB');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should format size in MB when 1 MB or larger', () => {
|
|
||||||
const viewModel = new MediaViewModel({
|
|
||||||
id: 'media-123',
|
|
||||||
url: 'https://example.com/image.jpg',
|
|
||||||
type: 'image',
|
|
||||||
uploadedAt: new Date().toISOString(),
|
|
||||||
size: 2048000, // 2 MB in base-10, ~1.95 MB in base-2
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(viewModel.formattedSize).toBe('1.95 MB');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle very small file sizes', () => {
|
|
||||||
const viewModel = new MediaViewModel({
|
|
||||||
id: 'media-123',
|
|
||||||
url: 'https://example.com/image.jpg',
|
|
||||||
type: 'image',
|
|
||||||
uploadedAt: new Date().toISOString(),
|
|
||||||
size: 1024, // 1 KB
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(viewModel.formattedSize).toBe('1.00 KB');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle very large file sizes', () => {
|
|
||||||
const viewModel = new MediaViewModel({
|
|
||||||
id: 'media-123',
|
|
||||||
url: 'https://example.com/video.mp4',
|
|
||||||
type: 'video',
|
|
||||||
uploadedAt: new Date().toISOString(),
|
|
||||||
size: 104857600, // 100 MB
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(viewModel.formattedSize).toBe('100.00 MB');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support all media types', () => {
|
|
||||||
const imageVm = new MediaViewModel({
|
|
||||||
id: '1',
|
id: '1',
|
||||||
url: 'image.jpg',
|
src: 'a.jpg',
|
||||||
type: 'image',
|
title: 'A',
|
||||||
uploadedAt: new Date().toISOString(),
|
category: 'misc',
|
||||||
});
|
});
|
||||||
const videoVm = new MediaViewModel({
|
const withDate = new MediaViewModel({
|
||||||
id: '2',
|
id: '2',
|
||||||
url: 'video.mp4',
|
src: 'b.jpg',
|
||||||
type: 'video',
|
title: 'B',
|
||||||
uploadedAt: new Date().toISOString(),
|
category: 'misc',
|
||||||
|
date: '2023-01-15',
|
||||||
});
|
});
|
||||||
const docVm = new MediaViewModel({
|
const withDimensions = new MediaViewModel({
|
||||||
id: '3',
|
id: '3',
|
||||||
url: 'doc.pdf',
|
src: 'c.jpg',
|
||||||
type: 'document',
|
title: 'C',
|
||||||
uploadedAt: new Date().toISOString(),
|
category: 'misc',
|
||||||
|
dimensions: '800×600',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(imageVm.type).toBe('image');
|
expect(noMeta.hasMetadata).toBe(false);
|
||||||
expect(videoVm.type).toBe('video');
|
expect(withDate.hasMetadata).toBe(true);
|
||||||
expect(docVm.type).toBe('document');
|
expect(withDimensions.hasMetadata).toBe(true);
|
||||||
});
|
|
||||||
|
|
||||||
it('should support all media categories', () => {
|
|
||||||
const categories = ['avatar', 'team-logo', 'league-cover', 'race-result'] as const;
|
|
||||||
|
|
||||||
categories.forEach(category => {
|
|
||||||
const viewModel = new MediaViewModel({
|
|
||||||
id: 'media-123',
|
|
||||||
url: 'https://example.com/image.jpg',
|
|
||||||
type: 'image',
|
|
||||||
category,
|
|
||||||
uploadedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(viewModel.category).toBe(category);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,37 +1,40 @@
|
|||||||
import type { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO';
|
import type { MediaViewData } from '@/lib/view-data/MediaViewData';
|
||||||
|
|
||||||
|
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||||
|
|
||||||
|
type MediaAssetViewData = MediaViewData['assets'][number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Media View Model
|
* Media View Model
|
||||||
*
|
*
|
||||||
* Represents media information for the UI layer
|
* Client-only ViewModel created from ViewData (never DTO).
|
||||||
|
* Represents a single media asset card in the UI.
|
||||||
*/
|
*/
|
||||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
|
||||||
|
|
||||||
export class MediaViewModel extends ViewModel {
|
export class MediaViewModel extends ViewModel {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
src: string;
|
||||||
type: 'image' | 'video' | 'document';
|
title: string;
|
||||||
category?: 'avatar' | 'team-logo' | 'league-cover' | 'race-result';
|
category: string;
|
||||||
uploadedAt: Date;
|
date?: string;
|
||||||
size?: number;
|
dimensions?: string;
|
||||||
|
|
||||||
constructor(dto: GetMediaOutputDTO) {
|
constructor(viewData: MediaAssetViewData) {
|
||||||
this.id = dto.id;
|
super();
|
||||||
this.url = dto.url;
|
this.id = viewData.id;
|
||||||
this.type = dto.type as 'image' | 'video' | 'document';
|
this.src = viewData.src;
|
||||||
this.uploadedAt = new Date(dto.uploadedAt);
|
this.title = viewData.title;
|
||||||
if (dto.category !== undefined) this.category = dto.category as 'avatar' | 'team-logo' | 'league-cover' | 'race-result';
|
this.category = viewData.category;
|
||||||
if (dto.size !== undefined) this.size = dto.size;
|
if (viewData.date !== undefined) this.date = viewData.date;
|
||||||
|
if (viewData.dimensions !== undefined) this.dimensions = viewData.dimensions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI-specific: Formatted file size */
|
/** UI-specific: Combined subtitle used by MediaCard */
|
||||||
get formattedSize(): string {
|
get subtitle(): string {
|
||||||
if (!this.size) return 'Unknown';
|
return `${this.category}${this.dimensions ? ` • ${this.dimensions}` : ''}`;
|
||||||
const kb = this.size / 1024;
|
}
|
||||||
|
|
||||||
if (kb < 1024) return `${kb.toFixed(2)} KB`;
|
/** UI-specific: Whether any metadata is present */
|
||||||
|
get hasMetadata(): boolean {
|
||||||
const mb = kb / 1024;
|
return !!this.date || !!this.dimensions;
|
||||||
return `${mb.toFixed(2)} MB`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user