Files
star-map/src/app/core/data/article.service.spec.ts
T
SenrokaiandClaude Fable 5 bd37bb8b96 Say what is written about a world, when asked
Every figure this map shows is a measurement or something derived from one,
and it says which. What it could not do was tell you what a place is — a
radius and an eccentricity do not say that Titan is the only moon with a dense
atmosphere. An About control on a body's panel now fetches the Wikipedia lead,
and it is labelled as what it is: prose from another site, credited and linked
back, below the line where this map's own figures end.

Only on the press. Nothing is fetched while a body loads, and nothing is
fetched twice.

Three things the encyclopedia does that had to be handled, all found by asking
it rather than by guessing:

It redirects, generously — "Proxima Cen b" lands on "Proxima Centauri b" and
"Kepler-22 b" on "Kepler-22b" — so the catalogue's own names can be sent as
they are, with no mapping table to maintain.

It disambiguates. "Titan" is a list of everything called Titan, and so are
"Mercury" and "Io". Wikipedia says so in the response, and this app happens to
know the kind, so a disambiguation is retried as "Titan (moon)". Only in
English: every wiki words its own qualifiers, and inventing a translation of
one would be inventing an article title.

And it rate-limits, which it did to me while I was checking the above. A
refusal to answer is not an empty answer, so the two are separate outcomes:
"Wikipedia has no article on this" is about the world, "Wikipedia could not be
reached" is about this minute — and only the first is remembered, so a second
press is allowed to try again.

The extract is capped and scrollable. A lead can run a dozen lines, and this
panel is anchored to the top of a viewport that may be shorter than the prose.

Verified: build clean, 606/606 unit including twelve for the lookup chain, 16/16
end-to-end including three that stub the encyclopedia — this suite tests the
panel, not Wikipedia — design detector clean, and the real thing exercised by
hand against Earth, Titan and Proxima Cen b at three viewport sizes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-21 14:40:51 +02:00

160 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ArticleService } from './article.service';
function summary(overrides: Record<string, unknown> = {}): Response {
return new Response(
JSON.stringify({
type: 'standard',
titles: { normalized: 'Titan (moon)' },
extract: 'Titan is the largest moon of Saturn.',
content_urls: { desktop: { page: 'https://en.wikipedia.org/wiki/Titan_(moon)' } },
...overrides
}),
{ status: 200 }
);
}
function missing(): Response {
return new Response('{}', { status: 404 });
}
/** What Wikipedia sends anyone who asks too quickly. */
function rateLimited(): Response {
return new Response('You are making too many requests to the API.', { status: 429 });
}
function service(): ArticleService {
TestBed.resetTestingModule();
return TestBed.inject(ArticleService);
}
/** The language each call was made in, in order, so the fallback chain can be asserted. */
function languagesAsked(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls.map((call) => new URL(String(call[0])).hostname.split('.')[0]);
}
function titlesAsked(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls.map((call) => decodeURIComponent(String(call[0]).split('/summary/')[1]));
}
describe('ArticleService', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('navigator', { language: 'en-GB' });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('hands back what Wikipedia wrote, and where to go and check it', async () => {
fetchMock.mockResolvedValue(summary());
const result = await service().lookup('Titan');
expect(result).toEqual({
status: 'found',
article: {
title: 'Titan (moon)',
extract: 'Titan is the largest moon of Saturn.',
url: 'https://en.wikipedia.org/wiki/Titan_(moon)',
language: 'en'
}
});
});
it('asks in the browser’s language first and English second', async () => {
vi.stubGlobal('navigator', { language: 'fr-FR' });
fetchMock.mockResolvedValueOnce(missing()).mockResolvedValueOnce(summary());
const result = await service().lookup('Titan');
expect(languagesAsked(fetchMock)).toEqual(['fr', 'en']);
expect(result.status).toBe('found');
});
it('asks English only once, when English is what the browser is set to', async () => {
fetchMock.mockResolvedValue(summary());
await service().lookup('Titan');
expect(languagesAsked(fetchMock)).toEqual(['en']);
});
it('names the kind when the plain name lands on a list of other things', async () => {
fetchMock.mockResolvedValueOnce(summary({ type: 'disambiguation' })).mockResolvedValueOnce(summary());
const result = await service().lookup('Titan', 'moon');
expect(titlesAsked(fetchMock)).toEqual(['Titan', 'Titan (moon)']);
expect(result.status).toBe('found');
});
it('says there is nothing written rather than pretending, when nothing is', async () => {
fetchMock.mockResolvedValue(missing());
expect(await service().lookup('HD 224700', 'planet')).toEqual({ status: 'none' });
});
it('tells being unable to ask apart from there being no answer', async () => {
fetchMock.mockRejectedValue(new TypeError('offline'));
expect(await service().lookup('Titan')).toEqual({ status: 'unavailable' });
});
it('counts the rate limit as being unable to ask, not as an empty answer', async () => {
fetchMock.mockResolvedValue(rateLimited());
expect(await service().lookup('Titan')).toEqual({ status: 'unavailable' });
});
it('asks once per body, however many times it is asked for', async () => {
fetchMock.mockResolvedValue(summary());
const articles = service();
await articles.lookup('Titan');
await articles.lookup('Titan');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('remembers that there is nothing, but never that it could not ask', async () => {
const articles = service();
fetchMock.mockResolvedValue(missing());
await articles.lookup('Nowhere');
await articles.lookup('Nowhere');
const afterMissing = fetchMock.mock.calls.length;
fetchMock.mockRejectedValue(new TypeError('offline'));
await articles.lookup('Elsewhere');
const afterFirstFailure = fetchMock.mock.calls.length;
await articles.lookup('Elsewhere');
// One round of requests for the missing page, then nothing more; but a failure to reach
// Wikipedia is a fact about this minute, so pressing again is allowed to try again.
expect(afterMissing).toBe(1);
expect(fetchMock.mock.calls.length).toBeGreaterThan(afterFirstFailure);
});
it('treats an article with nothing in it as no article', async () => {
fetchMock.mockResolvedValue(summary({ extract: ' ' }));
expect(await service().lookup('Titan')).toEqual({ status: 'none' });
});
it('does not send a browser language that is not one', async () => {
vi.stubGlobal('navigator', { language: 'not a language tag' });
fetchMock.mockResolvedValue(summary());
await service().lookup('Titan');
expect(languagesAsked(fetchMock)).toEqual(['en']);
});
});