From bd37bb8b9603ef13e0e91c121e8138076dff22da Mon Sep 17 00:00:00 2001 From: Senrokai Date: Fri, 21 Aug 2026 14:40:51 +0200 Subject: [PATCH 1/2] 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(['/']); From 7c16ad8627953824863c57913d44882556d3840a Mon Sep 17 00:00:00 2001 From: Senrokai Date: Fri, 21 Aug 2026 15:41:38 +0200 Subject: [PATCH 2/2] Answer the review: a null byte in the source, and a language that may not be there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings, and the first is the kind a person does not catch. The article cache keyed on `${name}\0${qualifier}` — with the null byte written into the file rather than escaped into the string. Git calls a file with one of those binary and stops diffing it, and every editor between here and a reader does something different with it. The separator was the right idea, because a name can contain a space and `("Kepler-22 b", none)` and `("Kepler-22", "b")` are different questions; it just has to be spelled `\0`. And `navigator.language` is optional in the DOM's own typings and missing in some embedded engines, where splitting it would have thrown on the first press of About rather than falling back to English. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- src/app/core/data/article.service.ts | Bin 5252 -> 5528 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/app/core/data/article.service.ts b/src/app/core/data/article.service.ts index a6bbbcc226e41ac5446c36840760d7f890f357db..9ec7f45f65567a4306976f0123c15d07b78574d9 100644 GIT binary patch delta 314 zcmYk2F;2rk5Je><3*nxE?&BsgI1Wk~38kS!h=LLd&w7W=O7^btu8C4X!41+NPJxtL zaf*yn(T)Dh{P$Os2sgpL(%tTL(U9jxPlGvU9{d&1M3jijJwANG**F} z2C2XKG!exfi5JgwRpVODP-)sHN>_97)LDaUpnA)9jwRhWpuK0hJZP2E=}FnqMW2 w&Z}eQ|8v=_K&CS3Z2Zt@_z}6WfuZ<#Y#3>81BdYkVs2_J!{&9| GuQ&k-Iuh3a