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:
2026-08-21 14:40:51 +02:00
co-authored by Claude Fable 5
parent d9bd913458
commit bd37bb8b96
4 changed files with 295 additions and 3 deletions
+62
View File
@@ -0,0 +1,62 @@
import { expect, test } from '@playwright/test';
/** Every request the article service makes, whichever language it is asking in. */
const isWikipedia = (url: URL): boolean => url.hostname.endsWith('wikipedia.org');
/** The article Wikipedia would send, stubbed: this suite tests the panel, not the encyclopedia. */
const SUMMARY = {
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)' } }
};
test.describe('About', () => {
test('fetches an article only when asked, and says where it came from', async ({ page }) => {
let requests = 0;
await page.route(isWikipedia, async (route) => {
requests++;
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SUMMARY) });
});
await page.goto('/body/titan');
await expect(page.getByRole('heading', { name: 'Titan' })).toBeVisible({ timeout: 30_000 });
// Nothing has been fetched yet: the panel is measurements until a reader asks for prose.
expect(requests).toBe(0);
await expect(page.getByText('Titan is the largest moon')).toHaveCount(0);
await page.getByRole('button', { name: 'About', exact: true }).click();
await expect(page.getByText('Titan is the largest moon of Saturn.')).toBeVisible();
const credit = page.getByRole('link', { name: /Wikipedia/ });
await expect(credit).toHaveAttribute('href', 'https://en.wikipedia.org/wiki/Titan_(moon)');
expect(requests).toBeGreaterThan(0);
});
test('says nothing is written rather than leaving the press unanswered', async ({ page }) => {
await page.route(isWikipedia, (route) => route.fulfill({ status: 404, body: '{}' }));
await page.goto('/body/titan');
await expect(page.getByRole('heading', { name: 'Titan' })).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'About', exact: true }).click();
await expect(page.getByText(/Wikipedia has no article on Titan/)).toBeVisible();
});
test('tells being unable to ask apart from there being no answer, and offers to try again', async ({ page }) => {
await page.route(isWikipedia, (route) => route.abort('failed'));
await page.goto('/body/titan');
await expect(page.getByRole('heading', { name: 'Titan' })).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'About', exact: true }).click();
await expect(page.getByText(/could not be reached/)).toBeVisible();
// And the retry actually retries, rather than reading back a remembered failure.
await page.unroute(isWikipedia);
await page.route(isWikipedia, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SUMMARY) }));
await page.getByRole('button', { name: 'Try again' }).click();
await expect(page.getByText('Titan is the largest moon of Saturn.')).toBeVisible();
});
});
+159
View File
@@ -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.
@@ -1,6 +1,7 @@
import { Component, computed, inject, input } from '@angular/core';
import { Component, computed, effect, inject, input, signal } from '@angular/core';
import { Router } from '@angular/router';
import { Article, ArticleService } from '../../core/data/article.service';
import { BookmarksStore } from '../../shared/state/bookmarks.store';
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
@@ -50,6 +51,47 @@ import { ReadoutSectionsComponent } from './readout-sections.component';
</header>
<app-readout-sections [readouts]="readouts()" />
<!-- Prose from elsewhere, and only when asked for. Everything above this line is a
measurement or something derived from one; this is a person's paragraph on another
site, so it says whose and links back to it. -->
@if (article(); as found) {
<!-- Capped and scrollable: a Wikipedia lead can run to a dozen lines, and this panel is
anchored to the top of a viewport that may be a good deal shorter than the prose. -->
<div class="max-h-56 overflow-y-auto border-t border-border/40 px-4 py-3">
<p class="text-[11px] leading-relaxed text-muted">{{ found.extract }}</p>
<a
[href]="found.url"
target="_blank"
rel="noopener"
class="type-label mt-2 inline-block text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent"
>Wikipedia · {{ found.language }}</a
>
</div>
} @else if (aboutState() !== 'idle') {
<p class="border-t border-border/40 px-4 py-3 text-[11px] leading-relaxed text-muted">
@switch (aboutState()) {
@case ('loading') {
Asking Wikipedia…
}
@case ('none') {
Wikipedia has no article on {{ body().name }}.
}
@case ('unavailable') {
Wikipedia could not be reached.
<button type="button" (click)="loadArticle()" class="text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent">Try again</button>
}
}
</p>
} @else {
<button
type="button"
(click)="loadArticle()"
class="type-label w-full border-t border-border/40 px-4 py-2 text-left text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:bg-accent/12 focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
>
About
</button>
}
</div>
`
})
@@ -58,9 +100,38 @@ export class InfoPanelComponent {
readonly bookmarks = inject(BookmarksStore);
readonly readouts = computed(() => bodyReadouts(this.body()));
private readonly articles = inject(ArticleService);
constructor(private readonly router: Router) {}
readonly article = signal<Article | null>(null);
readonly aboutState = signal<'idle' | 'loading' | 'none' | 'unavailable'>('idle');
constructor(private readonly router: Router) {
// The panel is reused as the route's parameter changes, so what was asked about one body
// must not still be showing under the next one's name.
effect(() => {
this.body();
this.article.set(null);
this.aboutState.set('idle');
});
}
/**
* Fetches the article, on the press and not before. The kind goes with the name because
* Wikipedia disambiguates by it — "Titan" alone is a list of everything called Titan.
*/
async loadArticle(): Promise<void> {
this.aboutState.set('loading');
const result = await this.articles.lookup(this.body().name, this.readouts().kindLabel.toLowerCase());
if (result.status === 'found') {
this.article.set(result.article);
this.aboutState.set('idle');
return;
}
this.article.set(null);
this.aboutState.set(result.status);
}
readonly readouts = computed(() => bodyReadouts(this.body()));
goBack(): void {
void this.router.navigate(['/']);