Add star-map Angular app, ETL pipeline, and caveman plugin

Angular 3D star map (galaxy/system/body views, Three.js rendering,
navigation store) plus the NASA ETL tooling that builds the star,
exoplanet and solar-system datasets, Playwright e2e suite, and the
cs:caveman Claude Code plugin (command, agent, skill).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
2026-08-03 16:50:10 +02:00
parent 1e1b58b0e9
commit d7e8ea1d4d
99 changed files with 5513 additions and 1 deletions
+30
View File
@@ -0,0 +1,30 @@
# End-to-end tests (Playwright)
These tests run the real Angular dev server in a real Chromium browser, covering interactions
that unit/component tests (under `src/**/*.spec.ts`, run via `npm test`) can only approximate
under jsdom — most importantly the galaxy↔system **camera-flight transitions** (real WebGPU/
WebGL2 initialization + raycaster picking) and cross-view **search navigation**.
## Running
```bash
npm run e2e # headless run against a freshly started dev server
npx playwright test --ui # interactive UI mode
npx playwright show-report
```
`playwright.config.ts` starts `npm run start -- --port=4300` automatically and waits for it to
respond before running the suite (`webServer.reuseExistingServer` is `true` outside CI, so an
already-running `ng serve` on port 4300 is reused instead of starting a second one). Port 4300
is used instead of the Angular CLI's conventional 4200 to avoid colliding with an unrelated
server a developer might already have running there.
## Notes
- The Sun (`Sol`) is always placed at the coordinate-system origin (`x=0,y=0,z=0`), which is
exactly where the default galaxy-view camera looks. `camera-flight.spec.ts` relies on this to
reliably click-select it by clicking the center of the canvas, without needing pixel-perfect
knowledge of the star field's on-screen layout.
- Data (bootstrap fetch of `stars.bin`/`stars-index.json`/`bodies.json`/`exoplanets.json`) loads
asynchronously after the page loads, so tests poll (re-click/re-check) rather than assume the
scene is interactive immediately after `page.goto()`.
+21
View File
@@ -0,0 +1,21 @@
import { expect, test } from '@playwright/test';
import { backButtonLocator, clickCanvasUntilSystemEntered } from './support/wait-for-back-button';
test.describe('Camera-flight transitions (click-to-select)', () => {
test('clicking the star at the view center flies into its system, and the back button flies back out to the galaxy overview', async ({ page }) => {
await page.goto('/');
const canvas = page.getByTestId('scene-canvas');
await expect(canvas).toBeVisible();
const backButton = backButtonLocator(page);
await expect(backButton).toHaveCount(0);
// Sol sits at the coordinate system's origin, exactly where the default galaxy-view camera
// looks, so clicking the canvas center reliably hits it once bootstrap/picking are ready.
await clickCanvasUntilSystemEntered(page, canvas, backButton);
await backButton.click();
await expect(backButton).toHaveCount(0, { timeout: 15_000 });
});
});
+11
View File
@@ -0,0 +1,11 @@
import { expect, test } from '@playwright/test';
test.describe('Galaxy view', () => {
test('boots the app, initializes the 3D scene, and starts in the galaxy overview (no system controls shown)', async ({ page }) => {
await page.goto('/');
await expect(page.getByTestId('scene-canvas')).toBeVisible();
await expect(page.getByPlaceholder('Search stars, planets, exoplanets…')).toBeVisible();
await expect(page.getByRole('button', { name: 'Galaxy' })).toHaveCount(0);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { expect, test } from '@playwright/test';
import { backButtonLocator } from './support/wait-for-back-button';
test.describe('Search-driven navigation', () => {
test('selecting a star result flies into that system and shows the back-to-galaxy control', async ({ page }) => {
await page.goto('/');
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…');
await searchInput.fill('Proxima Centauri');
const result = page.getByRole('button', { name: /Proxima Centauri/ });
await expect(result).toBeVisible();
await result.click();
// Selecting a result clears the search query immediately (before the flight even starts).
await expect(searchInput).toHaveValue('');
await expect(backButtonLocator(page)).toBeVisible({ timeout: 15_000 });
});
test('selecting a body result navigates straight to its detail route and shows real NASA data', async ({ page }) => {
await page.goto('/');
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…');
await searchInput.fill('Earth');
const result = page.getByText('Earth', { exact: true });
await expect(result).toBeVisible();
await result.click();
await expect(page).toHaveURL(/\/body\/earth$/);
// The body-detail route is lazy-loaded; the first navigation to it in a dev-server session
// can take a few seconds to bundle/compile, so this allows more time than the default 5s.
await expect(page.getByRole('heading', { name: 'Earth' })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText('Planet · Sol')).toBeVisible();
await expect(page.getByText('Radius')).toBeVisible();
});
test('typing fewer than two characters shows no results, and Escape clears the query', async ({ page }) => {
await page.goto('/');
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…');
await searchInput.fill('E');
await expect(page.getByTestId('search-results')).toHaveCount(0);
await searchInput.fill('Earth');
await expect(page.getByTestId('search-results').locator('li')).not.toHaveCount(0);
await searchInput.press('Escape');
await expect(searchInput).toHaveValue('');
await expect(page.getByTestId('search-results')).toHaveCount(0);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { expect, Locator, Page } from '@playwright/test';
/**
* The "Galaxy" back button is only rendered while `NavigationStore.viewLevel() === 'system'`,
* which becomes true only once a full galaxy-to-system camera-flight transition has settled.
* Bootstrap (data fetch + raycaster wiring) finishes asynchronously after `page.goto()`, so
* this repeatedly clicks the canvas (re-checking first, so it never clicks a system-view body
* marker by accident once the transition has already completed) until the button appears.
*/
export function backButtonLocator(page: Page): Locator {
return page.getByRole('button', { name: 'Galaxy' });
}
export async function clickCanvasUntilSystemEntered(page: Page, canvas: Locator, backButton: Locator): Promise<void> {
await expect
.poll(
async () => {
if (await backButton.isVisible()) {
return true;
}
await canvas.click();
return false;
},
{ timeout: 15_000, intervals: [300] }
)
.toBe(true);
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "bundler",
"rootDir": "..",
"outDir": "../out-tsc/e2e",
"types": ["node", "@playwright/test"]
},
"include": ["**/*.ts"]
}