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..a6bbbcc Binary files /dev/null and b/src/app/core/data/article.service.ts differ 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(['/']);