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
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
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']);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
Reference in New Issue
Block a user