Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideTranslateService } from '@ngx-translate/core';

import { ConfigService } from './config.service';
import { MasterDataService } from './masterdata.service';

/**
* Masterdata almost never wins the race against the first render: every alarm list paints its cards
* from the alarm rows, which come back first, and resolves each species name through
* `MasterDataService`. If the maps are not reactive, that first paint is also the last one and the
* cards keep the `Pokemon #1` fallback until something else happens to redraw them -- which is what
* a route change does, and why the names look right on the second visit.
*
* Every test here flushes the responses *after* the first read, because seeding the service first
* passes just as happily against the broken code.
*/
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-masterdata-host',
standalone: true,
template: '<h3>{{ masterData.getPokemonName(1) }}</h3>',
})
class MasterDataHostComponent {
readonly masterData = inject(MasterDataService);
}

describe('MasterDataService — data arriving after first render', () => {
const API = 'http://test-api';
let httpMock: HttpTestingController;
let service: MasterDataService;

function flushMasterData(): void {
httpMock.expectOne(`${API}/api/masterdata/pokemon`).flush({ '1': 'Bulbasaur' });
httpMock.expectOne(`${API}/api/masterdata/items`).flush({ '1': 'Poke Ball' });
httpMock.expectOne(`${API}/api/masterdata/moves`).flush({ '13': 'Wrap' });
httpMock.expectOne(`${API}/api/masterdata/costumes`).flush({ '85': 'Halloween 2025' });
httpMock.expectOne(req => req.url === `${API}/api/masterdata/monsters`).flush({});
}

beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideTranslateService(),
{ provide: ConfigService, useValue: { apiHost: API } },
],
});
service = TestBed.inject(MasterDataService);
httpMock = TestBed.inject(HttpTestingController);
});

afterEach(() => httpMock.verify());

it('repaints a rendered species name once the names land', () => {
const fixture = TestBed.createComponent(MasterDataHostComponent);
service.loadData().subscribe();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('h3').textContent).toBe('Pokemon #1');

flushMasterData();
fixture.detectChanges();

expect(fixture.nativeElement.querySelector('h3').textContent).toBe('Bulbasaur');
});

it('keeps the fallback when the names never land', () => {
const fixture = TestBed.createComponent(MasterDataHostComponent);
service.loadData().subscribe();
fixture.detectChanges();

httpMock.expectOne(`${API}/api/masterdata/pokemon`).error(new ProgressEvent('error'), { status: 500, statusText: 'Error' });
httpMock.match(`${API}/api/masterdata/items`);
httpMock.match(`${API}/api/masterdata/moves`);
httpMock.match(`${API}/api/masterdata/costumes`);
httpMock.match(req => req.url === `${API}/api/masterdata/monsters`);
fixture.detectChanges();

expect(fixture.nativeElement.querySelector('h3').textContent).toBe('Pokemon #1');
});

it('re-resolves item and move names read before the load finished', () => {
const item = TestBed.runInInjectionContext(() => service.getItemName(1));
const move = TestBed.runInInjectionContext(() => service.getMoveName(13));
expect(item).toBe('Item #1');
expect(move).toBe('Move #13');

service.loadData().subscribe();
flushMasterData();

expect(service.getItemName(1)).toBe('Poke Ball');
expect(service.getMoveName(13)).toBe('Wrap');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,25 @@ const UNTRANSLATED_KEY = /^(poke|poke_type|form)_\d+$/;
@Injectable({ providedIn: 'root' })
export class MasterDataService {
private readonly config = inject(ConfigService);
private costumeMap = new Map<number, string>();
/**
* The name maps are signals, not plain Maps, because they are filled long after the first paint.
* Every reader below runs inside a template or a computed, so a signal read is what makes the
* alarm cards repaint when the names finally land -- a mutated Map would leave them showing
* `Pokemon #1` until something unrelated redrew them. See the late-arrival spec.
*/
private readonly costumeMap = signal(new Map<number, string>());
private readonly evoBaseMap = new Map<number, number>();

private readonly formsMap = signal(new Map<number, { id: number; name: string }[]>());
private readonly http = inject(HttpClient);
private readonly i18n = inject(I18nService);
private itemMap = new Map<number, string>();
private readonly itemMap = signal(new Map<number, string>());
private loaded = false;
/** Locale of the data currently in the maps, so a display-language change can be detected. */
private loadedLocale = '';
private loadRequested = false;
private moveMap = new Map<number, string>();
private pokemonMap = new Map<number, string>();
private readonly moveMap = signal(new Map<number, string>());
private readonly pokemonMap = signal(new Map<number, string>());
private readonly ready$ = new ReplaySubject<boolean>(1);
private readonly typeLabels = signal(new Map<string, string>());
private readonly typesMap = signal(new Map<number, string[]>());
Expand All @@ -62,12 +68,12 @@ export class MasterDataService {

/** Whether any costume names loaded. False means the dialogs offer only the two sentinels. */
costumesAvailable(): boolean {
return this.costumeMap.size > 0;
return this.costumeMap().size > 0;
}

getAllItems(): { id: number; name: string }[] {
const entries: { id: number; name: string }[] = [];
this.itemMap.forEach((name, id) => {
this.itemMap().forEach((name, id) => {
entries.push({ id, name });
});
entries.sort((a, b) => a.name.localeCompare(b.name));
Expand All @@ -77,7 +83,7 @@ export class MasterDataService {
getAllPokemon(): PokemonEntry[] {
const types = this.typesMap();
const entries: PokemonEntry[] = [{ id: 0, name: 'All Pokemon' }];
this.pokemonMap.forEach((name, id) => {
this.pokemonMap().forEach((name, id) => {
entries.push({ id, name, types: types.get(id) });
});
entries.sort((a, b) => a.id - b.id);
Expand Down Expand Up @@ -107,7 +113,7 @@ export class MasterDataService {
* unknown form renders.
*/
getCostumeName(id: number): string {
return this.costumeMap.get(id) ?? this.i18n.instant('POKEMON.COSTUME_FALLBACK', { id });
return this.costumeMap().get(id) ?? this.i18n.instant('POKEMON.COSTUME_FALLBACK', { id });
}

/**
Expand All @@ -119,7 +125,7 @@ export class MasterDataService {
*/
getCostumes(): { id: number; name: string }[] {
const entries: { id: number; name: string }[] = [];
this.costumeMap.forEach((name, id) => {
this.costumeMap().forEach((name, id) => {
entries.push({ id, name });
});
entries.sort((a, b) => b.id - a.id);
Expand All @@ -138,16 +144,16 @@ export class MasterDataService {
}

getItemName(id: number): string {
return this.itemMap.get(id) ?? `Item #${id}`;
return this.itemMap().get(id) ?? `Item #${id}`;
}

getMoveName(id: number): string {
return this.moveMap.get(id) ?? `Move #${id}`;
return this.moveMap().get(id) ?? `Move #${id}`;
}

getPokemonName(id: number): string {
if (id === 0) return 'All Pokemon';
return this.pokemonMap.get(id) ?? `Pokemon #${id}`;
return this.pokemonMap().get(id) ?? `Pokemon #${id}`;
}

getPokemonTypes(id: number): string[] {
Expand Down Expand Up @@ -179,7 +185,7 @@ export class MasterDataService {
* A null payload (upstream unreachable) leaves the English names from /api/masterdata/pokemon in
* place rather than blanking the selector.
*/
private applyMonsters(monsters: null | Record<string, MonsterEntry>): void {
private applyMonsters(monsters: null | Record<string, MonsterEntry>, names: Map<number, string>): void {
if (!monsters) return;

const namesById = new Map<number, string>();
Expand Down Expand Up @@ -244,7 +250,7 @@ export class MasterDataService {
forms.sort((a, b) => a.name.localeCompare(b.name));
}

namesById.forEach((name, id) => this.pokemonMap.set(id, name));
namesById.forEach((name, id) => names.set(id, name));
this.formsMap.set(grouped);
this.typesMap.set(typeMap);
this.typeLabels.set(typeLabelMap);
Expand Down Expand Up @@ -309,35 +315,44 @@ export class MasterDataService {
this.ready$.next(true);
},
next: ({ costumes, items, monsters, moves, pokemon }) => {
this.pokemonMap.clear();
// Each map is rebuilt whole and published once. Mutating the live map in place would not
// notify anything reading it, and would briefly show a half-filled list to anything that
// did.
const pokemonNames = new Map<number, string>();
if (pokemon) {
Object.entries(pokemon).forEach(([id, name]) => {
this.pokemonMap.set(Number(id), name as string);
pokemonNames.set(Number(id), name as string);
});
}

this.itemMap.clear();
const itemNames = new Map<number, string>();
if (items) {
Object.entries(items).forEach(([id, name]) => {
this.itemMap.set(Number(id), name as string);
itemNames.set(Number(id), name as string);
});
}

this.costumeMap.clear();
const costumeNames = new Map<number, string>();
if (costumes) {
Object.entries(costumes).forEach(([id, name]) => {
this.costumeMap.set(Number(id), name as string);
costumeNames.set(Number(id), name as string);
});
}

this.moveMap.clear();
const moveNames = new Map<number, string>();
if (moves) {
Object.entries(moves).forEach(([id, name]) => {
this.moveMap.set(Number(id), name as string);
moveNames.set(Number(id), name as string);
});
}

this.applyMonsters(monsters);
// Translated species names overwrite the English ones, so this runs before publishing.
this.applyMonsters(monsters, pokemonNames);

this.itemMap.set(itemNames);
this.costumeMap.set(costumeNames);
this.moveMap.set(moveNames);
this.pokemonMap.set(pokemonNames);

this.loaded = true;
this.ready$.next(true);
Expand Down
Loading
Loading