diff --git a/e2e/about.spec.ts b/e2e/about.spec.ts new file mode 100644 index 0000000..8df0108 --- /dev/null +++ b/e2e/about.spec.ts @@ -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(); + }); +}); diff --git a/src/app/core/data/article.service.spec.ts b/src/app/core/data/article.service.spec.ts new file mode 100644 index 0000000..a82a781 --- /dev/null +++ b/src/app/core/data/article.service.spec.ts @@ -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 = {}): 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): string[] { + return fetchMock.mock.calls.map((call) => new URL(String(call[0])).hostname.split('.')[0]); +} + +function titlesAsked(fetchMock: ReturnType): string[] { + return fetchMock.mock.calls.map((call) => decodeURIComponent(String(call[0]).split('/summary/')[1])); +} + +describe('ArticleService', () => { + let fetchMock: ReturnType; + + 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']); + }); +}); diff --git a/src/app/core/data/article.service.ts b/src/app/core/data/article.service.ts new file mode 100644 index 0000000..9ec7f45 --- /dev/null +++ b/src/app/core/data/article.service.ts @@ -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(); + + /** + * 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 { + // 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
{ + 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'; + } + } +} diff --git a/src/app/features/body-detail/info-panel.component.ts b/src/app/features/body-detail/info-panel.component.ts index 565414f..24180fc 100644 --- a/src/app/features/body-detail/info-panel.component.ts +++ b/src/app/features/body-detail/info-panel.component.ts @@ -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'; + + + @if (article(); as found) { + +
+

{{ found.extract }}

+ Wikipedia · {{ found.language }} +
+ } @else if (aboutState() !== 'idle') { +

+ @switch (aboutState()) { + @case ('loading') { + Asking Wikipedia… + } + @case ('none') { + Wikipedia has no article on {{ body().name }}. + } + @case ('unavailable') { + Wikipedia could not be reached. + + } + } +

+ } @else { + + } ` }) @@ -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
(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 { + 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(['/']);