From bd37bb8b9603ef13e0e91c121e8138076dff22da Mon Sep 17 00:00:00 2001 From: Senrokai Date: Fri, 21 Aug 2026 14:40:51 +0200 Subject: [PATCH] Say what is written about a world, when asked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- e2e/about.spec.ts | 62 +++++++ src/app/core/data/article.service.spec.ts | 159 ++++++++++++++++++ src/app/core/data/article.service.ts | Bin 0 -> 5252 bytes .../body-detail/info-panel.component.ts | 77 ++++++++- 4 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 e2e/about.spec.ts create mode 100644 src/app/core/data/article.service.spec.ts create mode 100644 src/app/core/data/article.service.ts 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 0000000000000000000000000000000000000000..a6bbbcc226e41ac5446c36840760d7f890f357db GIT binary patch literal 5252 zcmb7IZEqXL5$&yf+1c#nOS-0C)K)dJQi5E&+X?$HCE;k-mK!oFQl3BgjdCQKhTO*NT|qBrXYym% zNog0z9>Fp8TE-B~@y*f-CLWKl0#_C+xvtHbTpq z97pZUoX&BI4YELctGt(*#L~2Igw&n~oAZ84oDR?6o=*;6?A!q}Py^0GW*G$V121=c zj_gd*-q^Y-V9{~JKQtXZZj9Ns4PAnw9Z8%QzIAgNtr(xtMgJx-VcwDJNS~gy)Cgw; zra%9-)#6^LnyUee!2Hr^VEEnj&CTDgU;pso=K9C)-(P?KEy5Z}olB!*%*YW)k;{Xm zt{Ma0PiY^RwK8>$szB70vM4%7OseyLSo2Zs1ic2-%X0!RD)`Y1<;hDgTB~@)UinSV zNGP_AxaWL%LtAb3fLi##wpFW3uS^%wds~km(+(6Hmfj>6MJi9`@i4_DU#5ROzL*sr_>R--3HHw` z=T8Zmfn^eP^i0H5(oJb4JvWM2q5uz7-Zd&1vE4g>z7tI%^-8T%IFu15aM4UB9H ziQ-R{wCWxfI5Eo?hrne#-U6r?%?*uTU1vB+>98hI4{&Cp&`Mc!7mg~^*hw&_ z>ScwUm%@wMtXm0FjnEvs=boF5*g=R?A@U{iBtp@LylhyM4fonDY@)1i=g-EkdUkBY zQu)}Azv-uf@qrV2uaKcP8m-qF^}+ zN=dt5a&jvn4~958f2TYEV8nLD=<~?Y!PDv#M{Z~5^S+Ft{{|V*@ZppLC)n9mapqz& zup>`F8~h`6$Ux}Kz?XjFaVMv4EujM3ugOE@JXaKpLfnFj;{j!{Rh&PF0_L0`Fx}Cj zf`qZ>QP4s^^c4ra=YuECN9Q>LD;PqK0rOHTons|IJOU93?drg#2LAWz zPDR#>DRog@T zU{S>l8f~Wtp9-KjhL_MqiPVb5an&U4!((6Qt?_Ym2j%I#b|rb4b*7hy;qC$h(XR2B zK@MFR=hLDS4waOSw|5BhHta%F6h)`KK}((v6;}j{Iokid7RoG36L-OAHK80eg~y zD^y{JVV>-{G`ok72YTKmrYhW%y2JNL8?==U=M+&wIwG2|POlj4fN`f=KVb&4@;7u; z^!$Zx>w1w!l5SJLV(N(ar5*ki{7 z7?(Tu)*y0FoiO`tLTdSu0d0UlaLW3Tr-G;>CzYIgaPFsF8;gU&Q!&9Mj&a3*CrA1^ zb^X|i9IhM=WcpdgzhYf@2#~#$ zzMvRE?Q4Z`4Ogz&ub2h-qIt}$;W-XpFZ8Y zWE39TewL3_nC%mTXLk*741tN|$n!X$8OH|PdFAtsCli!#cO*0^T$(!dj}bA#=_{OP z-h>|Q@Ztb++8rjg-(}UU<10AZ^ACc-cM<*rzPE|%E9ti0G{KfjV_?UDHs-Z^grC+_n0#O_^#gXn+r7^C1HI0zX*QB&Huju COS^ag literal 0 HcmV?d00001 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(['/']);