Merge pull request #12 from avalon-vanguard/feat/hud-about
Say what is written about a world, when asked
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']);
|
||||
});
|
||||
});
|
||||
@@ -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(['/']);
|
||||
|
||||
Reference in New Issue
Block a user