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
160 lines
5.2 KiB
TypeScript
160 lines
5.2 KiB
TypeScript
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']);
|
||
});
|
||
});
|