This commit is contained in:
nessar committed 2025-11-09 00:00:36 +01:00
1 parent f46969549e
commit b9c25369c7
15 files changed
+189 -147

No files matched your search

+16
View File
@@ -3,6 +3,7 @@
## Quick Start
Run all tests:
```bash
npm test
```
@@ -31,25 +32,30 @@ src/app/
## What's Tested
### Components (4)
- ✅ **App** - Main application component
- ✅ **ChampionRandomizerComponent** - Core randomization logic
- ✅ **ControlHeaderComponent** - User controls
- ✅ **LaneCardComponent** - Individual lane display
### Services (3)
- ✅ **ChampionDataService** - Data fetching and caching
- ✅ **ImagePreloadService** - Image preloading
- ✅ **RandomNumber** - Secure random generation
### Directives (1)
- ✅ **PreloadLinkDirective** - Link preloading
### Interceptors (1)
- ✅ **imageCacheInterceptor** - HTTP caching
## Key Test Scenarios
### Champion Randomizer
- Random champion assignment for each lane
- Lane enable/disable functionality
- Reroll system with bank management
@@ -58,6 +64,7 @@ src/app/
- Copy draft to clipboard
### Champion Data Service
- API data fetching
- LocalStorage caching (24-hour expiry)
- Cache validation and invalidation
@@ -65,11 +72,13 @@ src/app/
- Champion filtering and sorting
### Random Number Service
- Cryptographically secure random numbers
- Range validation
- Edge case handling
### Image Management
- Cache control headers
- Image preloading
- Duplicate prevention
@@ -78,6 +87,7 @@ src/app/
## Test Configuration
The project uses:
- **Framework**: Jasmine
- **Runner**: Karma
- **Browser**: Chrome (or ChromeHeadless for CI)
@@ -85,6 +95,7 @@ The project uses:
## Continuous Integration
For CI environments without a display:
```bash
npm test -- --watch=false --browsers=ChromeHeadless
```
@@ -92,6 +103,7 @@ npm test -- --watch=false --browsers=ChromeHeadless
## Code Coverage
Generate coverage report:
```bash
ng test --no-watch --code-coverage
```
@@ -110,20 +122,24 @@ Coverage report will be available in `coverage/` directory.
## Troubleshooting
### Tests won't run
- Ensure all dependencies are installed: `npm install`
- Check that Chrome is installed for Karma
### Specific test failing
- Run tests in watch mode: `npm test`
- Check console output for specific error messages
### Coverage not generating
- Ensure you're using the `--code-coverage` flag
- Check that all test files end with `.spec.ts`
## Contributing
When adding new features:
1. Write tests first (TDD approach)
2. Ensure all tests pass
3. Maintain high code coverage
+17
View File
@@ -5,12 +5,14 @@ This document provides an overview of all the tests created for the Random LOL a
## Test Files Created/Updated
### 1. **App Component Tests** (`src/app/app.spec.ts`)
- ✅ Component creation
- ✅ ImagePreloadService injection
- ✅ Champion randomizer component rendering
- ✅ Toolbar rendering
### 2. **Champion Randomizer Component Tests** (`src/app/components/champion-randomizer.component.spec.ts`)
- ✅ Component creation and initialization
- ✅ Lane management (enable/disable lanes)
- ✅ Champion assignment and rolling
@@ -26,6 +28,7 @@ This document provides an overview of all the tests created for the Random LOL a
- ✅ Shift key tracking for force rerolls
### 3. **Control Header Component Tests** (`src/app/components/control-header/control-header.component.spec.ts`)
- ✅ Component creation
- ✅ All input properties (isLoading, hasChampions, loadError, canCopyDraft, rerollBank, rerollBankMax)
- ✅ Output events (roll, retry, copyDraft)
@@ -33,6 +36,7 @@ This document provides an overview of all the tests created for the Random LOL a
- ✅ Template rendering (spinner, error messages)
### 4. **Lane Card Component Tests** (`src/app/components/lane-card/lane-card.component.spec.ts`)
- ✅ Component creation
- ✅ All input properties (lane, laneLabel, champion, canReRoll, isLoading, hasChampions, isShiftPressed, disabled)
- ✅ Output events (changeChampion, toggleDisableLane)
@@ -40,6 +44,7 @@ This document provides an overview of all the tests created for the Random LOL a
- ✅ Template rendering (champion name, image, lane label, role chips, disabled styling)
### 5. **Champion Data Service Tests** (`src/app/services/champion-data.service.spec.ts`)
- ✅ Service creation
- ✅ Loading champions from API
- ✅ HTTP to HTTPS URL conversion
@@ -58,11 +63,13 @@ This document provides an overview of all the tests created for the Random LOL a
- ✅ Invalid cache data handling
### 6. **Image Preload Service Tests** (`src/app/services/image-preload.service.spec.ts`)
- ✅ Service creation
- ✅ Cache clearing functionality
- ✅ Error handling during cache operations
### 7. **Random Number Service Tests** (`src/app/services/random-number.spec.ts`)
- ✅ Service creation
- ✅ Random number generation within range
- ✅ Edge case: max = 1
@@ -73,12 +80,14 @@ This document provides an overview of all the tests created for the Random LOL a
- ✅ crypto.getRandomValues usage verification
### 8. **Preload Link Directive Tests** (`src/app/directives/preload-link.directive.spec.ts`)
- ✅ Directive creation
- ✅ Preload link addition to document head
- ✅ Fetchpriority attribute setting
- ✅ Duplicate link prevention
### 9. **Image Cache Interceptor Tests** (`src/app/interceptors/image-cache.interceptor.spec.ts`)
- ✅ Cache header addition for PNG images
- ✅ Cache header addition for JPG images
- ✅ Cache header addition for JPEG images
@@ -93,6 +102,7 @@ This document provides an overview of all the tests created for the Random LOL a
## Test Statistics
### Total Test Suites: 9
- App: 4 tests
- Champion Randomizer: 29 tests
- Control Header: 10 tests
@@ -108,20 +118,24 @@ This document provides an overview of all the tests created for the Random LOL a
## Coverage Areas
### ✅ Components (100%)
- App
- ChampionRandomizerComponent
- ControlHeaderComponent
- LaneCardComponent
### ✅ Services (100%)
- ChampionDataService
- ImagePreloadService
- RandomNumber
### ✅ Directives (100%)
- PreloadLinkDirective
### ✅ Interceptors (100%)
- imageCacheInterceptor
## Key Testing Features
@@ -138,16 +152,19 @@ This document provides an overview of all the tests created for the Random LOL a
## Running the Tests
To run all tests:
```bash
npm test
```
To run tests in headless mode (requires Chrome):
```bash
npm test -- --watch=false --browsers=ChromeHeadless
```
To run tests with coverage:
```bash
ng test --no-watch --code-coverage
```
@@ -23,4 +23,4 @@
.warning-card mat-icon {
font-size: 1.25rem;
}
}
@@ -12,18 +12,18 @@
></app-control-header>
<div class="lane-grid">
@for (assignment of laneAssignments(); track assignment.lane) {
<app-lane-card
[lane]="assignment.lane"
[laneLabel]="laneLabel(assignment.lane)"
[champion]="assignment.champion"
[canReRoll]="reRollBank() > 0"
[isLoading]="isLoading()"
[hasChampions]="hasChampions()"
[isShiftPressed]="isShiftPressed()"
[disabled]="disabledLanes().has(assignment.lane)"
(changeChampion)="changeChampion($event.lane, $event.forceReroll)"
(toggleDisableLane)="toggleLane($event)"
></app-lane-card>
<app-lane-card
[lane]="assignment.lane"
[laneLabel]="laneLabel(assignment.lane)"
[champion]="assignment.champion"
[canReRoll]="reRollBank() > 0"
[isLoading]="isLoading()"
[hasChampions]="hasChampions()"
[isShiftPressed]="isShiftPressed()"
[disabled]="disabledLanes().has(assignment.lane)"
(changeChampion)="changeChampion($event.lane, $event.forceReroll)"
(toggleDisableLane)="toggleLane($event)"
></app-lane-card>
}
</div>
</section>
@@ -66,7 +66,7 @@ describe('ChampionRandomizerComponent', () => {
championsByLane: championsByLaneSignal.asReadonly(),
loading: loadingSignal.asReadonly(),
error: errorSignal.asReadonly(),
}
},
);
championDataSpy.ensureLoaded.and.returnValue(Promise.resolve());
@@ -83,7 +83,7 @@ describe('ChampionRandomizerComponent', () => {
}).compileComponents();
championDataService = TestBed.inject(
ChampionDataService
ChampionDataService,
) as jasmine.SpyObj<ChampionDataService>;
fixture = TestBed.createComponent(ChampionRandomizerComponent);
component = fixture.componentInstance;
@@ -230,7 +230,7 @@ describe('ChampionRandomizerComponent', () => {
['mid', null],
['adc', null],
['support', null],
])
]),
);
// Set reroll bank to 0.5 (less than 1)
@@ -286,7 +286,7 @@ describe('ChampionRandomizerComponent', () => {
['mid', mockChampions[1]],
['adc', mockChampions[2]],
['support', mockChampions[3]],
])
]),
);
expect(component['canCopyDraft']()).toBe(true);
@@ -339,7 +339,7 @@ describe('ChampionRandomizerComponent', () => {
}
spyOn(navigator.clipboard, 'writeText').and.returnValue(
Promise.reject(new Error('Clipboard error'))
Promise.reject(new Error('Clipboard error')),
);
spyOn(console, 'error');
component['rollAssignments']();
@@ -63,7 +63,7 @@ export class ChampionRandomizerComponent {
protected readonly hasChampions = computed(() => this.champions().length > 0);
protected readonly assignments = signal<Map<Lane, Champion | null>>(
this.createEmptyAssignments()
this.createEmptyAssignments(),
);
protected readonly disabledLanesArray = computed(() => {
@@ -104,8 +104,8 @@ export class ChampionRandomizerComponent {
(champion) =>
!this.assignmentsArray().some(
(assignedChampion) =>
assignedChampion && assignedChampion.name === champion.name
)
assignedChampion && assignedChampion.name === champion.name,
),
) || [];
notUsed.set(lane, champion);
}
@@ -125,7 +125,7 @@ export class ChampionRandomizerComponent {
champion,
rolesText,
} satisfies LaneAssignmentView;
})
}),
);
protected readonly canCopyDraft = computed<boolean>(() => {
@@ -185,7 +185,7 @@ export class ChampionRandomizerComponent {
for (const lane of this.activatedLanesArray()) {
const candidates = (championsByLaneMap.get(lane) || []).filter(
(champion: Champion) => !selected.has(champion.name)
(champion: Champion) => !selected.has(champion.name),
);
if (candidates.length === 0) {
@@ -36,18 +36,18 @@
<span class="centered-text">{{ rerollBank() }}</span>
</div>
@if (isLoading()) {
<div class="status mat-body-small" aria-live="polite">
<mat-icon aria-hidden="true">autorenew</mat-icon>
<span>Loading champion data&hellip;</span>
</div>
<div class="status mat-body-small" aria-live="polite">
<mat-icon aria-hidden="true">autorenew</mat-icon>
<span>Loading champion data&hellip;</span>
</div>
} @else if (loadError(); as errorMessage) {
<div class="status error mat-body-small" aria-live="polite">
<mat-icon aria-hidden="true" color="warn">error</mat-icon>
<span>{{ errorMessage }}</span>
<button type="button" mat-button color="accent" (click)="onRetry()">
<mat-icon aria-hidden="true">refresh</mat-icon>
<span>Retry</span>
</button>
</div>
<div class="status error mat-body-small" aria-live="polite">
<mat-icon aria-hidden="true" color="warn">error</mat-icon>
<span>{{ errorMessage }}</span>
<button type="button" mat-button color="accent" (click)="onRetry()">
<mat-icon aria-hidden="true">refresh</mat-icon>
<span>Retry</span>
</button>
</div>
}
</header>
@@ -72,7 +72,6 @@
}
@keyframes pulse {
0%,
100% {
opacity: 1;
@@ -132,4 +131,4 @@
.enable-lane-button {
margin-left: auto;
}
}
@@ -1,104 +1,114 @@
@if (disabled()) {
<mat-card class="lane-card disabled-card">
<mat-card-header class="mat-header">
<mat-card-title-group>
<mat-card-title>{{ laneLabel() }}</mat-card-title>
<mat-card-subtitle>Lane currently disabled</mat-card-subtitle>
</mat-card-title-group>
</mat-card-header>
<mat-card-content>
<div class="disabled-message">
<mat-icon class="disabled-icon" aria-hidden="true">block</mat-icon>
<p class="mat-body-large">This lane has been disabled</p>
<p class="mat-body-small">You chose to exclude this lane from the random selection.</p>
</div>
<button
type="button"
matFab
extended
color="primary"
class="enable-lane-button"
(click)="onToggleDisableLane()"
>
<mat-icon aria-hidden="true">add_circle</mat-icon>
Enable Lane
</button>
</mat-card-content>
</mat-card>
<mat-card class="lane-card disabled-card">
<mat-card-header class="mat-header">
<mat-card-title-group>
<mat-card-title>{{ laneLabel() }}</mat-card-title>
<mat-card-subtitle>Lane currently disabled</mat-card-subtitle>
</mat-card-title-group>
</mat-card-header>
<mat-card-content>
<div class="disabled-message">
<mat-icon class="disabled-icon" aria-hidden="true">block</mat-icon>
<p class="mat-body-large">This lane has been disabled</p>
<p class="mat-body-small">
You chose to exclude this lane from the random selection.
</p>
</div>
<button
type="button"
matFab
extended
color="primary"
class="enable-lane-button"
(click)="onToggleDisableLane()"
>
<mat-icon aria-hidden="true">add_circle</mat-icon>
Enable Lane
</button>
</mat-card-content>
</mat-card>
} @else {
<mat-card class="lane-card">
<mat-card-header class="mat-header">
<mat-card-title-group>
<mat-card-title>{{ laneLabel() }}</mat-card-title>
<mat-card-subtitle>
{{ champion() ? 'Unique pick locked in' : 'Champion needed for this lane' }}
</mat-card-subtitle>
</mat-card-title-group>
<button
matMiniFab
[disabled]="isLoading() || !hasChampions()"
(click)="onToggleDisableLane()"
>
<mat-icon aria-hidden="true">close</mat-icon>
</button>
</mat-card-header>
<mat-card-content>
@if (champion(); as champ) {
<div class="champion-details">
<img
[ngSrc]="champ.icon"
width="96"
height="96"
[class.loading-image]="isLoading()"
[alt]="champ.name + ' portrait'"
[priority]="true"
class="champion-icon"
/>
<p class="champion-name mat-title-medium">{{ champ.name }}</p>
</div>
<mat-chip-set class="roles-set" [attr.aria-label]="'Roles for ' + champ.name">
@for (role of champ.roles; track role) {
<mat-chip>{{ roleLabel(role) }}</mat-chip>
}
</mat-chip-set>
} @else {
<p class="champion-name champion-name-empty mat-title-medium">No champion available</p>
<p class="champion-roles mat-body-small">Roll again once champions finish loading.</p>
} @if (canReRoll()) {
<button
type="button"
matFab
extended
color="accent"
class="change-champion-button"
[disabled]="isLoading() || !hasChampions()"
(click)="onChangeChampion(false)"
>
<mat-icon aria-hidden="true">refresh</mat-icon>
Re-Roll !
</button>
} @else {
<button
type="button"
matFab
extended
color="accent"
class="change-champion-button"
[disabled]="isLoading() || !hasChampions() || (!isShiftPressed() && !canReRoll())"
(click)="onChangeChampion(isShiftPressed())"
[class.shift-active]="isShiftPressed()"
>
@if (!isShiftPressed()) {
<mat-icon aria-hidden="true">heart_broken</mat-icon>
<mat-card class="lane-card">
<mat-card-header class="mat-header">
<mat-card-title-group>
<mat-card-title>{{ laneLabel() }}</mat-card-title>
<mat-card-subtitle>
{{ champion() ? 'Unique pick locked in' : 'Champion needed for this lane' }}
</mat-card-subtitle>
</mat-card-title-group>
<button
matMiniFab
[disabled]="isLoading() || !hasChampions()"
(click)="onToggleDisableLane()"
>
<mat-icon aria-hidden="true">close</mat-icon>
</button>
</mat-card-header>
<mat-card-content>
@if (champion(); as champ) {
<div class="champion-details">
<img
[ngSrc]="champ.icon"
width="96"
height="96"
[class.loading-image]="isLoading()"
[alt]="champ.name + ' portrait'"
[priority]="true"
class="champion-icon"
/>
<p class="champion-name mat-title-medium">{{ champ.name }}</p>
</div>
<mat-chip-set class="roles-set" [attr.aria-label]="'Roles for ' + champ.name">
@for (role of champ.roles; track role) {
<mat-chip>{{ roleLabel(role) }}</mat-chip>
}
</mat-chip-set>
} @else {
<mat-icon aria-hidden="true">lock_open_right</mat-icon>
} @if (!isShiftPressed()) {
<p>No re-rolls left</p>
} @else {
<p>Force Re-Roll!</p>
<p class="champion-name champion-name-empty mat-title-medium">
No champion available
</p>
<p class="champion-roles mat-body-small">
Roll again once champions finish loading.
</p>
}
</button>
}
</mat-card-content>
</mat-card>
@if (canReRoll()) {
<button
type="button"
matFab
extended
color="accent"
class="change-champion-button"
[disabled]="isLoading() || !hasChampions()"
(click)="onChangeChampion(false)"
>
<mat-icon aria-hidden="true">refresh</mat-icon>
Re-Roll !
</button>
} @else {
<button
type="button"
matFab
extended
color="accent"
class="change-champion-button"
[disabled]="
isLoading() || !hasChampions() || (!isShiftPressed() && !canReRoll())
"
(click)="onChangeChampion(isShiftPressed())"
[class.shift-active]="isShiftPressed()"
>
@if (!isShiftPressed()) {
<mat-icon aria-hidden="true">heart_broken</mat-icon>
} @else {
<mat-icon aria-hidden="true">lock_open_right</mat-icon>
}
@if (!isShiftPressed()) {
<p>No re-rolls left</p>
} @else {
<p>Force Re-Roll!</p>
}
</button>
}
</mat-card-content>
</mat-card>
}
@@ -34,7 +34,7 @@ describe('PreloadLinkDirective', () => {
setTimeout(() => {
const preloadLinks = document.querySelectorAll('link[rel="preload"][as="image"]');
const hasTestLink = Array.from(preloadLinks).some((link) =>
link.getAttribute('href')?.includes('test.png')
link.getAttribute('href')?.includes('test.png'),
);
expect(hasTestLink).toBe(true);
done();
@@ -47,10 +47,10 @@ describe('PreloadLinkDirective', () => {
setTimeout(() => {
const preloadLinks = document.querySelectorAll(
'link[rel="preload"][as="image"][fetchpriority="high"]'
'link[rel="preload"][as="image"][fetchpriority="high"]',
);
const hasTestLink = Array.from(preloadLinks).some((link) =>
link.getAttribute('href')?.includes('test.png')
link.getAttribute('href')?.includes('test.png'),
);
expect(hasTestLink).toBe(true);
done();
@@ -66,7 +66,7 @@ describe('PreloadLinkDirective', () => {
setTimeout(() => {
const preloadLinks = document.querySelectorAll(
'link[rel="preload"][href="https://example.com/test.png"]'
'link[rel="preload"][href="https://example.com/test.png"]',
);
// Should only have one link, not duplicates
expect(preloadLinks.length).toBeLessThanOrEqual(1);
@@ -79,7 +79,7 @@ describe('imageCacheInterceptor', () => {
.subscribe();
const req = httpMock.expectOne(
'https://ddragon.leagueoflegends.com/cdn/14.1.1/img/champion/Aatrox.png'
'https://ddragon.leagueoflegends.com/cdn/14.1.1/img/champion/Aatrox.png',
);
expect(req.request.headers.get('Cache-Control')).toBe('public, max-age=86400, immutable');
req.flush({});
@@ -194,7 +194,7 @@ describe('ChampionDataService', () => {
const champions = service.champions();
for (let i = 1; i < champions.length; i++) {
expect(champions[i - 1].name.localeCompare(champions[i].name)).toBeLessThanOrEqual(
0
0,
);
}
});
+3 -3
View File
@@ -134,7 +134,7 @@ export class ChampionDataService {
try {
const response = await firstValueFrom(
this.http.get<Record<string, RemoteChampion>>(CHAMPION_DATA_URL)
this.http.get<Record<string, RemoteChampion>>(CHAMPION_DATA_URL),
);
const champions = Object.entries(response)
@@ -174,8 +174,8 @@ export class ChampionDataService {
new Set(
data.positions
.map((position) => POSITION_TO_LANE[position])
.filter((role): role is Lane => role !== undefined)
)
.filter((role): role is Lane => role !== undefined),
),
);
if (roles.length === 0 || !data.icon) {
@@ -40,7 +40,7 @@ describe('ImagePreloadService', () => {
});
championDataService = TestBed.inject(
ChampionDataService
ChampionDataService,
) as jasmine.SpyObj<ChampionDataService>;
service = TestBed.inject(ImagePreloadService);
});
+1 -1
View File
@@ -37,7 +37,7 @@ describe('RandomNumber', () => {
it('should throw RangeError for max > Number.MAX_SAFE_INTEGER', () => {
expect(() => RandomNumber.getSecureRandomInt(Number.MAX_SAFE_INTEGER + 1)).toThrowError(
RangeError
RangeError,
);
});