From 80318638c2c4b8011d3b41a9daf3d5d56bdf7954 Mon Sep 17 00:00:00 2001 From: Tony Date: Mon, 23 Mar 2026 20:57:19 +0200 Subject: [PATCH] added image fix by migrating images to the public folder Add tests for service and vehicle components --- {images => public/images}/16x9/fpace_k17.jpg | Bin {images => public/images}/16x9/ftype_k17.jpg | Bin {images => public/images}/16x9/ipace_k21.jpg | Bin {images => public/images}/16x9/xe_k17.jpg | Bin {images => public/images}/16x9/xf_k17.jpg | Bin {images => public/images}/16x9/xj_k16.jpg | Bin {images => public/images}/1x1/fpace_k17.jpg | Bin {images => public/images}/1x1/ftype_k17.jpg | Bin {images => public/images}/1x1/ipace_k21.jpg | Bin {images => public/images}/1x1/xe_k17.jpg | Bin {images => public/images}/1x1/xf_k17.jpg | Bin {images => public/images}/1x1/xj_k16.jpg | Bin src/app/app.html | 354 +----------------- src/app/app.spec.ts | 18 +- src/app/app.ts | 3 +- src/app/core/services/vehicle.service.spec.ts | 203 ++++++++++ src/app/core/services/vehicle.service.ts | 11 +- .../vehicle-card.component.spec.ts | 127 +++++++ .../vehicle-card/vehicle-card.component.ts | 1 + .../vehicle-list.component.spec.ts | 143 +++++++ .../vehicle-list/vehicle-list.component.ts | 11 +- 21 files changed, 507 insertions(+), 364 deletions(-) rename {images => public/images}/16x9/fpace_k17.jpg (100%) rename {images => public/images}/16x9/ftype_k17.jpg (100%) rename {images => public/images}/16x9/ipace_k21.jpg (100%) rename {images => public/images}/16x9/xe_k17.jpg (100%) rename {images => public/images}/16x9/xf_k17.jpg (100%) rename {images => public/images}/16x9/xj_k16.jpg (100%) rename {images => public/images}/1x1/fpace_k17.jpg (100%) rename {images => public/images}/1x1/ftype_k17.jpg (100%) rename {images => public/images}/1x1/ipace_k21.jpg (100%) rename {images => public/images}/1x1/xe_k17.jpg (100%) rename {images => public/images}/1x1/xf_k17.jpg (100%) rename {images => public/images}/1x1/xj_k16.jpg (100%) create mode 100644 src/app/core/services/vehicle.service.spec.ts create mode 100644 src/app/features/vehicles/components/vehicle-card/vehicle-card.component.spec.ts create mode 100644 src/app/features/vehicles/components/vehicle-list/vehicle-list.component.spec.ts diff --git a/images/16x9/fpace_k17.jpg b/public/images/16x9/fpace_k17.jpg similarity index 100% rename from images/16x9/fpace_k17.jpg rename to public/images/16x9/fpace_k17.jpg diff --git a/images/16x9/ftype_k17.jpg b/public/images/16x9/ftype_k17.jpg similarity index 100% rename from images/16x9/ftype_k17.jpg rename to public/images/16x9/ftype_k17.jpg diff --git a/images/16x9/ipace_k21.jpg b/public/images/16x9/ipace_k21.jpg similarity index 100% rename from images/16x9/ipace_k21.jpg rename to public/images/16x9/ipace_k21.jpg diff --git a/images/16x9/xe_k17.jpg b/public/images/16x9/xe_k17.jpg similarity index 100% rename from images/16x9/xe_k17.jpg rename to public/images/16x9/xe_k17.jpg diff --git a/images/16x9/xf_k17.jpg b/public/images/16x9/xf_k17.jpg similarity index 100% rename from images/16x9/xf_k17.jpg rename to public/images/16x9/xf_k17.jpg diff --git a/images/16x9/xj_k16.jpg b/public/images/16x9/xj_k16.jpg similarity index 100% rename from images/16x9/xj_k16.jpg rename to public/images/16x9/xj_k16.jpg diff --git a/images/1x1/fpace_k17.jpg b/public/images/1x1/fpace_k17.jpg similarity index 100% rename from images/1x1/fpace_k17.jpg rename to public/images/1x1/fpace_k17.jpg diff --git a/images/1x1/ftype_k17.jpg b/public/images/1x1/ftype_k17.jpg similarity index 100% rename from images/1x1/ftype_k17.jpg rename to public/images/1x1/ftype_k17.jpg diff --git a/images/1x1/ipace_k21.jpg b/public/images/1x1/ipace_k21.jpg similarity index 100% rename from images/1x1/ipace_k21.jpg rename to public/images/1x1/ipace_k21.jpg diff --git a/images/1x1/xe_k17.jpg b/public/images/1x1/xe_k17.jpg similarity index 100% rename from images/1x1/xe_k17.jpg rename to public/images/1x1/xe_k17.jpg diff --git a/images/1x1/xf_k17.jpg b/public/images/1x1/xf_k17.jpg similarity index 100% rename from images/1x1/xf_k17.jpg rename to public/images/1x1/xf_k17.jpg diff --git a/images/1x1/xj_k16.jpg b/public/images/1x1/xj_k16.jpg similarity index 100% rename from images/1x1/xj_k16.jpg rename to public/images/1x1/xj_k16.jpg diff --git a/src/app/app.html b/src/app/app.html index 1b67d43..9a395ab 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,351 +1,3 @@ - - - - - - - - - - - -
-
-
- -

Hello, {{ title() }}

-

Congratulations! Your app is running. 🎉

-
- -
-
- @for ( - item of [ - { title: 'Explore the Docs', link: 'https://angular.dev' }, - { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, - { - title: 'Prompt and best practices for AI', - link: 'https://angular.dev/ai/develop-with-ai', - }, - { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, - { - title: 'Angular Language Service', - link: 'https://angular.dev/tools/language-service', - }, - { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, - ]; - track item.title - ) { - - {{ item.title }} - - - - - } -
- -
-
-
- - - - - - - - +
+ +
\ No newline at end of file diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 1ad6dc8..bfd345e 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -1,23 +1,29 @@ import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; + import { App } from './app'; describe('App', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [App], + // App renders VehicleListComponent which injects VehicleService (HttpClient). + // provideHttpClientTesting() intercepts requests so no real network calls are made. + providers: [provideHttpClient(), provideHttpClientTesting()], }).compileComponents(); }); it('should create the app', () => { const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - expect(app).toBeTruthy(); + expect(fixture.componentInstance).toBeTruthy(); }); - it('should render title', async () => { + it('renders the vehicle list inside a main element', () => { const fixture = TestBed.createComponent(App); - await fixture.whenStable(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, angular-code-test'); + fixture.detectChanges(); + const main = fixture.nativeElement.querySelector('main'); + expect(main).not.toBeNull(); + expect(main.querySelector('app-vehicle-list')).not.toBeNull(); }); }); diff --git a/src/app/app.ts b/src/app/app.ts index 694fbf8..7402635 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,8 +1,9 @@ import { Component, signal } from '@angular/core'; +import { VehicleListComponent } from './features/vehicles/components/vehicle-list/vehicle-list.component'; @Component({ selector: 'app-root', - imports: [], + imports: [VehicleListComponent], templateUrl: './app.html', styleUrl: './app.scss', }) diff --git a/src/app/core/services/vehicle.service.spec.ts b/src/app/core/services/vehicle.service.spec.ts new file mode 100644 index 0000000..6f104d5 --- /dev/null +++ b/src/app/core/services/vehicle.service.spec.ts @@ -0,0 +1,203 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; + +import { VehicleService } from './vehicle.service'; +import { VehicleDetail, VehicleSummary } from '../interfaces/vehicle.interface'; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared test fixtures +// +// Define minimal objects that satisfy the interface. Keeping them at the top +// makes it easy to reuse across tests and update when the interface changes. +// ───────────────────────────────────────────────────────────────────────────── + +const API_BASE = + 'https://frontend-code-test-api-1023992580432.europe-west2.run.app'; + +const mockSummary: VehicleSummary = { + id: 'xe', + name: 'XE', + modelYear: '2021', + apiUrl: '/api/vehicles/xe', + media: [{ name: 'xe 16x9', url: '/images/16x9/xe.jpg' }], +}; + +const mockDetail: VehicleDetail = { + id: 'xe', + description: 'A sports saloon', + price: '£30,000', + meta: { + passengers: 5, + drivetrain: ['AWD'], + bodystyles: ['Saloon'], + emissions: { template: 'CO2 $value g/km', value: 129 }, + }, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Helper — flushes the list request then each vehicle detail request. +// Extracted so individual tests only need to describe their own scenario. +// ───────────────────────────────────────────────────────────────────────────── +function flushSuccess( + httpMock: HttpTestingController, + summaries = [mockSummary], + detail = mockDetail, +): void { + httpMock.expectOne(`${API_BASE}/api/vehicles/`).flush(summaries); + summaries.forEach((s) => + httpMock.expectOne(`${API_BASE}${s.apiUrl}`).flush(detail), + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +describe('VehicleService', () => { + let service: VehicleService; + let httpMock: HttpTestingController; + + // beforeEach runs before every `it` block. + // provideHttpClientTesting() intercepts real HTTP calls and lets us + // control responses manually — no real network requests are made. + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + + service = TestBed.inject(VehicleService); + httpMock = TestBed.inject(HttpTestingController); + }); + + // afterEach verifies no unexpected HTTP requests were made. + // If a test triggers a request it didn't explicitly expect, this will fail. + afterEach(() => httpMock.verify()); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + // ── Initial state ────────────────────────────────────────────────────────── + + describe('initial signal values', () => { + // Signals are just functions — call them with () to read the value. + it('loading starts as false', () => { + expect(service.loading()).toBe(false); + }); + + it('error starts as null', () => { + expect(service.error()).toBeNull(); + }); + + it('vehicles starts as an empty array', () => { + expect(service.vehicles()).toEqual([]); + }); + }); + + // ── fetchAll() — happy path ──────────────────────────────────────────────── + + describe('fetchAll() — success', () => { + it('sets loading to true immediately before any response arrives', () => { + service.fetchAll().subscribe(); + + // At this point the HTTP request is in-flight — loading should be true. + expect(service.loading()).toBe(true); + + // Clean up: satisfy the pending requests so afterEach verify() passes. + flushSuccess(httpMock); + }); + + it('populates the vehicles signal with merged data', () => { + service.fetchAll().subscribe(); + flushSuccess(httpMock); + + const vehicles = service.vehicles(); + expect(vehicles.length).toBe(1); + + // The merged vehicle should have fields from both summary and detail. + expect(vehicles[0].name).toBe('XE'); + expect(vehicles[0].price).toBe('£30,000'); + expect(vehicles[0].description).toBe('A sports saloon'); + }); + + it('sets loading to false after success', () => { + service.fetchAll().subscribe(); + flushSuccess(httpMock); + + expect(service.loading()).toBe(false); + }); + + it('clears any previous error on a fresh call', () => { + // Simulate a stale error from a previous failed call. + service.error.set('old error'); + + service.fetchAll().subscribe(); + + // The error should be cleared as soon as fetchAll() is called, + // before any response arrives. + expect(service.error()).toBeNull(); + + flushSuccess(httpMock); + }); + }); + + // ── fetchAll() — error paths ─────────────────────────────────────────────── + + describe('fetchAll() — list request fails', () => { + it('sets the error signal with the response message', () => { + service.fetchAll().subscribe(); + + httpMock + .expectOne(`${API_BASE}/api/vehicles/`) + .flush('Internal Server Error', { + status: 500, + statusText: 'Server Error', + }); + + expect(service.error()).toBeTruthy(); + }); + + it('sets loading to false after a list failure', () => { + service.fetchAll().subscribe(); + + httpMock + .expectOne(`${API_BASE}/api/vehicles/`) + .flush('Error', { status: 500, statusText: 'Server Error' }); + + expect(service.loading()).toBe(false); + }); + }); + + describe('fetchAll() — one detail request fails', () => { + // This tests the resilience pattern: a single bad vehicle record should + // not kill the entire list. The failing request emits null, which is + // filtered out, so the rest of the vehicles still appear. + it('still returns the other vehicles when one detail request fails', () => { + const secondSummary: VehicleSummary = { + ...mockSummary, + id: 'xf', + name: 'XF', + apiUrl: '/api/vehicles/xf', + }; + + service.fetchAll().subscribe(); + + httpMock + .expectOne(`${API_BASE}/api/vehicles/`) + .flush([mockSummary, secondSummary]); + + // XE succeeds, XF fails. + httpMock.expectOne(`${API_BASE}/api/vehicles/xe`).flush(mockDetail); + httpMock + .expectOne(`${API_BASE}/api/vehicles/xf`) + .flush('Not Found', { status: 404, statusText: 'Not Found' }); + + expect(service.vehicles().length).toBe(1); + expect(service.vehicles()[0].id).toBe('xe'); + }); + }); +}); diff --git a/src/app/core/services/vehicle.service.ts b/src/app/core/services/vehicle.service.ts index 6116ee4..eeaec17 100644 --- a/src/app/core/services/vehicle.service.ts +++ b/src/app/core/services/vehicle.service.ts @@ -6,6 +6,7 @@ import { catchError, forkJoin, map, + of, switchMap, } from 'rxjs'; import { @@ -21,6 +22,7 @@ const API_BASE = export class VehicleService { readonly loading = signal(false); readonly error = signal(null); + readonly vehicles = signal([]); constructor(private readonly http: HttpClient) {} @@ -41,14 +43,15 @@ export class VehicleService { .get(`${API_BASE}${summary.apiUrl}`) .pipe( map((detail) => this.merge(summary, detail)), - // Per-vehicle errors are swallowed so one bad record - // (e.g. "problematic") doesn't kill the entire list. - catchError(() => EMPTY), + // Per-vehicle errors emit null so forkJoin still completes. + catchError(() => of(null)), ), ), ), ), - map((vehicles) => { + map((results) => { + const vehicles = results.filter((v): v is AnyVehicle => v !== null); + this.vehicles.set(vehicles); this.loading.set(false); return vehicles; }), diff --git a/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.spec.ts b/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.spec.ts new file mode 100644 index 0000000..191c275 --- /dev/null +++ b/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.spec.ts @@ -0,0 +1,127 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { VehicleCardComponent } from './vehicle-card.component'; +import { AnyVehicle } from '../../../../core/interfaces/vehicle.interface'; + +// ───────────────────────────────────────────────────────────────────────────── +// Fixture data +// ───────────────────────────────────────────────────────────────────────────── + +const mockVehicle: AnyVehicle = { + id: 'xe', + name: 'XE', + modelYear: '2021', + apiUrl: '/api/vehicles/xe', + media: [ + { name: 'xe 16x9', url: '/images/16x9/xe.jpg' }, + { name: 'xe 1x1', url: '/images/1x1/xe.jpg' }, + ], + description: 'A sports saloon.', + price: '£30,000', + meta: { + passengers: 5, + drivetrain: ['AWD'], + bodystyles: ['Saloon'], + emissions: { template: 'CO2 $value g/km', value: 129 }, + }, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +describe('VehicleCardComponent', () => { + let component: VehicleCardComponent; + let fixture: ComponentFixture; + + // Standalone components are imported directly — no NgModule needed. + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VehicleCardComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(VehicleCardComponent); + component = fixture.componentInstance; + + // Set the required @Input before the first change detection cycle. + component.vehicle = mockVehicle; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + // ── imageUrl getter ──────────────────────────────────────────────────────── + + describe('imageUrl', () => { + // The getter prefers the 16x9 crop because CSS handles the visual + // crop at every breakpoint — no need to swap src on resize. + it('returns the 16x9 url when one exists in the media array', () => { + expect(component.imageUrl).toBe('/images/16x9/xe.jpg'); + }); + + it('falls back to the first media item when no 16x9 url exists', () => { + component.vehicle = { + ...mockVehicle, + media: [{ name: 'xe 4x3', url: '/images/4x3/xe.jpg' }], + }; + expect(component.imageUrl).toBe('/images/4x3/xe.jpg'); + }); + + it('returns an empty string when the media array is empty', () => { + component.vehicle = { ...mockVehicle, media: [] }; + expect(component.imageUrl).toBe(''); + }); + }); + + // ── emissionsLabel getter ────────────────────────────────────────────────── + + describe('emissionsLabel', () => { + it('replaces the $value placeholder with the numeric emissions value', () => { + expect(component.emissionsLabel).toBe('CO2 129 g/km'); + }); + }); + + // ── template ────────────────────────────────────────────────────────────── + // + // Query DOM elements by their BEM class name. + // textContent?.trim() guards against leading/trailing whitespace in the HTML. + + describe('template', () => { + let el: HTMLElement; + + beforeEach(() => { + el = fixture.nativeElement; + }); + + it('renders the vehicle name', () => { + expect(el.querySelector('.vehicle-card__name')?.textContent?.trim()).toBe( + 'XE', + ); + }); + + it('renders the vehicle price prefixed with "From"', () => { + expect( + el.querySelector('.vehicle-card__price')?.textContent?.trim(), + ).toContain('£30,000'); + }); + + it('renders the vehicle description', () => { + expect( + el.querySelector('.vehicle-card__description')?.textContent?.trim(), + ).toBe('A sports saloon.'); + }); + + it('sets the image src to the 16x9 url', () => { + const img = el.querySelector('.vehicle-card__image'); + // img.src is absolute in JSDOM, so use toContain rather than toBe. + expect(img?.src).toContain('/images/16x9/xe.jpg'); + }); + + it('sets the image alt to the vehicle name', () => { + const img = el.querySelector('.vehicle-card__image'); + expect(img?.alt).toBe('XE'); + }); + }); +}); diff --git a/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.ts b/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.ts index 380485a..062f437 100644 --- a/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.ts +++ b/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.ts @@ -9,6 +9,7 @@ import { AnyVehicle } from '../../../../core/interfaces/vehicle.interface'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class VehicleCardComponent { + // TODO refactor to use signals input @Input({ required: true }) vehicle!: AnyVehicle; /** diff --git a/src/app/features/vehicles/components/vehicle-list/vehicle-list.component.spec.ts b/src/app/features/vehicles/components/vehicle-list/vehicle-list.component.spec.ts new file mode 100644 index 0000000..e085a9b --- /dev/null +++ b/src/app/features/vehicles/components/vehicle-list/vehicle-list.component.spec.ts @@ -0,0 +1,143 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { of } from 'rxjs'; + +import { VehicleListComponent } from './vehicle-list.component'; +import { VehicleService } from '../../../../core/services/vehicle.service'; +import { AnyVehicle } from '../../../../core/interfaces/vehicle.interface'; + +// ───────────────────────────────────────────────────────────────────────────── +// Fixture data +// ───────────────────────────────────────────────────────────────────────────── + +const mockVehicle: AnyVehicle = { + id: 'xe', + name: 'XE', + modelYear: '2021', + apiUrl: '/api/vehicles/xe', + media: [{ name: 'xe 16x9', url: '/images/16x9/xe.jpg' }], + description: 'A sports saloon.', + price: '£30,000', + meta: { + passengers: 5, + drivetrain: ['AWD'], + bodystyles: ['Saloon'], + emissions: { template: 'CO2 $value g/km', value: 129 }, + }, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Mock service +// +// We create writable signals so individual tests can call .set() on them to +// simulate different states (loading, error, success) without making any +// real HTTP calls. fetchAll() returns of([]) by default — an Observable that +// completes immediately with an empty array. +// ───────────────────────────────────────────────────────────────────────────── + +function createMockVehicleService() { + return { + loading: signal(false), + error: signal(null), + vehicles: signal([]), + fetchAll: vi.fn().mockReturnValue(of([])), + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +describe('VehicleListComponent', () => { + let component: VehicleListComponent; + let fixture: ComponentFixture; + let mockService: ReturnType; + + beforeEach(async () => { + mockService = createMockVehicleService(); + + await TestBed.configureTestingModule({ + imports: [VehicleListComponent], + providers: [ + // Replace the real VehicleService with our mock. + // Angular's DI resolves VehicleService tokens to this object instead. + { provide: VehicleService, useValue: mockService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(VehicleListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + // ── Initialisation ───────────────────────────────────────────────────────── + + it('calls fetchAll() once on init', () => { + expect(mockService.fetchAll).toHaveBeenCalledTimes(1); + }); + + // ── Loading state ────────────────────────────────────────────────────────── + // + // detectChanges() must be called after mutating a signal so Angular + // re-evaluates the template bindings in the test environment. + + it('shows 6 skeleton placeholders while loading', () => { + mockService.loading.set(true); + fixture.detectChanges(); + + const skeletons = + fixture.nativeElement.querySelectorAll('.vehicle-list__skeleton'); + expect(skeletons.length).toBe(6); + }); + + it('does not show the vehicle grid while loading', () => { + mockService.loading.set(true); + fixture.detectChanges(); + + const grid = fixture.nativeElement.querySelector('.vehicle-list__grid[aria-label="Vehicle list"]'); + expect(grid).toBeNull(); + }); + + // ── Error state ──────────────────────────────────────────────────────────── + + it('shows the error message when the error signal has a value', () => { + mockService.error.set('Failed to load vehicles'); + fixture.detectChanges(); + + const errorEl = fixture.nativeElement.querySelector( + '.vehicle-list__error-message', + ); + expect(errorEl?.textContent?.trim()).toBe('Failed to load vehicles'); + }); + + it('does not show the skeleton grid in the error state', () => { + mockService.error.set('Some error'); + fixture.detectChanges(); + + const skeletons = + fixture.nativeElement.querySelectorAll('.vehicle-list__skeleton'); + expect(skeletons.length).toBe(0); + }); + + // ── Success state ────────────────────────────────────────────────────────── + + it('renders one vehicle card per vehicle in the signal', () => { + mockService.vehicles.set([mockVehicle, { ...mockVehicle, id: 'xf', name: 'XF' }]); + fixture.detectChanges(); + + const cards = fixture.nativeElement.querySelectorAll('app-vehicle-card'); + expect(cards.length).toBe(2); + }); + + it('does not show the error block in the success state', () => { + mockService.vehicles.set([mockVehicle]); + fixture.detectChanges(); + + const errorEl = fixture.nativeElement.querySelector('.vehicle-list__error'); + expect(errorEl).toBeNull(); + }); +}); diff --git a/src/app/features/vehicles/components/vehicle-list/vehicle-list.component.ts b/src/app/features/vehicles/components/vehicle-list/vehicle-list.component.ts index a366f60..3e80afd 100644 --- a/src/app/features/vehicles/components/vehicle-list/vehicle-list.component.ts +++ b/src/app/features/vehicles/components/vehicle-list/vehicle-list.component.ts @@ -1,4 +1,5 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { VehicleService } from '../../../../core/services/vehicle.service'; import { VehicleCardComponent } from '../vehicle-card/vehicle-card.component'; @@ -9,9 +10,15 @@ import { VehicleCardComponent } from '../vehicle-card/vehicle-card.component'; styleUrl: './vehicle-list.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class VehicleListComponent { +export class VehicleListComponent implements OnInit { protected readonly vehicleService = inject(VehicleService); /** Drives the skeleton loading grid — one placeholder per expected vehicle. */ protected readonly skeletonCount = Array.from({ length: 6 }); + + private readonly destroy$ = takeUntilDestroyed(); + + ngOnInit(): void { + this.vehicleService.fetchAll().pipe(this.destroy$).subscribe(); + } }