Merge pull request #12 from avalon-vanguard/feat/hud-about

Say what is written about a world, when asked
This commit is contained in:
Senrokai
2026-08-27 19:23:34 +02:00
committed by GitHub
4 changed files with 433 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']);
});
});
+138
View File
@@ -0,0 +1,138 @@
import { Injectable } from '@angular/core';
/** A Wikipedia summary, as much of it as this app shows. */
export interface Article {
readonly title: string;
readonly extract: string;
/** The article itself, so a reader can go and check. */
readonly url: string;
/** Which Wikipedia it came from — `en`, `fr`. Shown, because it is not always the one asked for. */
readonly language: string;
}
/**
* Found it, there is no article, or Wikipedia could not be reached. The last two are different
* facts and the panel says so: "nothing written about this" and "could not ask" are not the
* same, and only one of them is about the world.
*/
export type ArticleLookup = { readonly status: 'found'; readonly article: Article } | { readonly status: 'none' } | { readonly status: 'unavailable' };
const FALLBACK_LANGUAGE = 'en';
/** Wikipedia's own summary endpoint, which follows redirects — "Proxima Cen b" lands on
* "Proxima Centauri b" without this app having to know that. */
function summaryUrl(language: string, title: string): string {
return `https://${language}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`;
}
/** The primary subtag of whatever the browser is set to, or English where there is no browser. */
function browserLanguage(): string {
// `navigator.language` is optional in the DOM lib and absent in some embedded engines.
const tag = (typeof navigator === 'undefined' ? '' : navigator.language) ?? '';
const primary = tag.split('-')[0]?.toLowerCase();
return primary && /^[a-z]{2,3}$/.test(primary) ? primary : FALLBACK_LANGUAGE;
}
/**
* Wikipedia summaries, fetched only when asked for.
*
* The map's own figures are measurements; this is prose someone wrote, from somewhere else. It
* is never loaded with a body, only when a reader asks for it, and it is always labelled with
* where it came from and linked back to the article — the same rule the derived surfaces follow,
* for the same reason.
*
* Asked for in the browser's language first and English second, because the catalogue's names
* are English and the disambiguation this has to work around is an English-Wikipedia habit.
*/
@Injectable({ providedIn: 'root' })
export class ArticleService {
private readonly cache = new Map<string, ArticleLookup>();
/**
* The article for `name`, or why there is none.
*
* `qualifier` is what tells "Titan the moon" from "Titan the disambiguation page": Wikipedia
* parenthesises the kind, and this app happens to know it. Only English gets that treatment —
* every wiki words its own qualifiers — so a disambiguation anywhere else falls back to
* English rather than guessing at a translation.
*/
async lookup(name: string, qualifier?: string): Promise<ArticleLookup> {
// Separated by an escape rather than a space: a name may contain one, so `("Kepler-22 b",
// undefined)` and `("Kepler-22", "b")` would otherwise be the same question.
const key = `${name}\0${qualifier ?? ''}`;
const cached = this.cache.get(key);
if (cached) {
return cached;
}
const language = browserLanguage();
const attempts: { language: string; title: string }[] = [
...(language === FALLBACK_LANGUAGE ? [] : [{ language, title: name }]),
{ language: FALLBACK_LANGUAGE, title: name },
...(qualifier ? [{ language: FALLBACK_LANGUAGE, title: `${name} (${qualifier})` }] : [])
];
let reachedWikipedia = false;
for (const attempt of attempts) {
const result = await this.fetchSummary(attempt.language, attempt.title);
if (result === 'unreachable') {
continue;
}
reachedWikipedia = true;
if (result) {
const found: ArticleLookup = { status: 'found', article: result };
this.cache.set(key, found);
return found;
}
}
// Not cached when Wikipedia could not be reached: that is a fact about the network this
// minute, and the next press should be allowed to ask again.
const outcome: ArticleLookup = reachedWikipedia ? { status: 'none' } : { status: 'unavailable' };
if (reachedWikipedia) {
this.cache.set(key, outcome);
}
return outcome;
}
/**
* One request. `null` where Wikipedia answered but has nothing usable — a missing page, or a
* disambiguation, which is a list of things this is not. `'unreachable'` where it did not
* answer at all, including the rate limit it returns to anyone who asks too fast.
*/
private async fetchSummary(language: string, title: string): Promise<Article | null | 'unreachable'> {
let response: Response;
try {
response = await fetch(summaryUrl(language, title), { headers: { Accept: 'application/json' } });
} catch {
return 'unreachable';
}
if (response.status === 404) {
return null;
}
if (!response.ok) {
return 'unreachable';
}
try {
const body = (await response.json()) as {
type?: string;
extract?: string;
titles?: { normalized?: string };
content_urls?: { desktop?: { page?: string } };
};
const extract = body.extract?.trim();
if (!extract || body.type === 'disambiguation') {
return null;
}
return {
title: body.titles?.normalized ?? title,
extract,
url: body.content_urls?.desktop?.page ?? `https://${language}.wikipedia.org/wiki/${encodeURIComponent(title)}`,
language
};
} catch {
return 'unreachable';
}
}
}
@@ -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(['/']);