Shared base #1

Open
Senrokai wants to merge 11 commits from shared-base into main
22 changed files with 1566 additions and 921 deletions
+2
View File
@@ -1,4 +1,6 @@
# Editor configuration, see https://editorconfig.org # Editor configuration, see https://editorconfig.org
# Source: @avalon-vanguard/config. EditorConfig cannot extend a file, so each repo commits a copy:
# cp node_modules/@avalon-vanguard/config/.editorconfig .editorconfig
root = true root = true
[*] [*]
+27
View File
@@ -0,0 +1,27 @@
name: CI
# Gitea port of .github/workflows/ci.yml, through the organisation's shared workflow
# (avalon-vanguard/ci, node.yml): lint, typecheck, unit tests, production build, then the
# Playwright suite. Same triggers: pull requests and the branches they merge into, plus manual
# dispatch.
#
# A .gitea/workflows directory makes Gitea ignore .github/workflows, so pages.yml,
# data-refresh.yml and junie-review.yml now run on GitHub only, until they are ported.
on:
push:
branches: [main, develop]
pull_request:
workflow_dispatch:
# A second push to the same branch makes the first run's answer irrelevant.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
uses: avalon-vanguard/ci/.gitea/workflows/node.yml@v1
# Pas d'e2e pour l'instant : la carte 3D rendue en logiciel par Chromium, sur ng serve,
# dépasse le runner du VPS (17 tests, 7 en échec par délai dépassé en 16,8 min). À
# remettre (e2e: true) une fois le runner capable de les passer.
secrets: inherit
+1
View File
@@ -0,0 +1 @@
@avalon-vanguard:registry=https://git.avalonvanguard.com/api/packages/avalon-vanguard/npm/
+1 -12
View File
@@ -1,12 +1 @@
{ "@avalon-vanguard/config/prettier/angular"
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+37
View File
@@ -0,0 +1,37 @@
// @ts-check
const { defineConfig } = require('eslint/config');
const avalonBase = require('@avalon-vanguard/config/eslint');
const avalonAngular = require('@avalon-vanguard/config/eslint/angular');
module.exports = defineConfig([
{
// Build, test and Playwright output (see .gitignore): ESLint does not read .gitignore.
ignores: [
'dist/**',
'coverage/**',
'.angular/**',
'node_modules/**',
'out-tsc/**',
'tools/etl/.cache/**',
'e2e/test-output/**',
'test-results/**',
'playwright-report/**',
'blob-report/**',
],
},
{
files: ['**/*.ts'],
extends: [avalonBase, avalonAngular.typescript],
rules: {
'@angular-eslint/directive-selector': [
'error',
{ type: 'attribute', prefix: 'app', style: 'camelCase' },
],
'@angular-eslint/component-selector': [
'error',
{ type: 'element', prefix: 'app', style: 'kebab-case' },
],
},
},
avalonAngular.templates,
]);
+1433 -841
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -7,11 +7,13 @@
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test", "test": "ng test",
"lint": "eslint .",
"etl": "tsx tools/etl/build.ts", "etl": "tsx tools/etl/build.ts",
"etl:typecheck": "tsc -p tools/etl/tsconfig.json --noEmit", "etl:typecheck": "tsc -p tools/etl/tsconfig.json --noEmit",
"e2e": "playwright test", "e2e": "playwright test",
"e2e:typecheck": "tsc -p e2e/tsconfig.json --noEmit", "e2e:typecheck": "tsc -p e2e/tsconfig.json --noEmit",
"worker:typecheck": "tsc -p tsconfig.worker.json --noEmit" "worker:typecheck": "tsc -p tsconfig.worker.json --noEmit",
"typecheck": "npm run etl:typecheck && npm run e2e:typecheck && npm run worker:typecheck"
}, },
"private": true, "private": true,
"packageManager": "npm@11.12.1", "packageManager": "npm@11.12.1",
@@ -33,16 +35,21 @@
"@angular/build": "^22.0.6", "@angular/build": "^22.0.6",
"@angular/cli": "^22.0.6", "@angular/cli": "^22.0.6",
"@angular/compiler-cli": "^22.0.0", "@angular/compiler-cli": "^22.0.0",
"@avalon-vanguard/config": "^1.0.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1", "@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.3.2", "@tailwindcss/postcss": "^4.3.2",
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
"@types/three": "^0.185.1", "@types/three": "^0.185.1",
"angular-eslint": "^22.5.0",
"eslint": "^10.10.0",
"jsdom": "^28.0.0", "jsdom": "^28.0.0",
"postcss": "^8.5.19", "postcss": "^8.5.19",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
"tsx": "^4.23.1", "tsx": "^4.23.1",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.70.0",
"vitest": "^4.0.8" "vitest": "^4.0.8"
} }
} }
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"local>avalon-vanguard/renovate-config",
"local>avalon-vanguard/renovate-config:angular"
]
}
+3 -2
View File
@@ -1,4 +1,4 @@
import { NgZone } from '@angular/core'; import { Injector, NgZone, runInInjectionContext } from '@angular/core';
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
@@ -10,7 +10,8 @@ import { EngineService } from './engine.service';
* arithmetic that keeps them showing the same thing. * arithmetic that keeps them showing the same thing.
*/ */
function engineWithCameras(): { engine: EngineService; perspective: THREE.PerspectiveCamera; orthographic: THREE.OrthographicCamera } { function engineWithCameras(): { engine: EngineService; perspective: THREE.PerspectiveCamera; orthographic: THREE.OrthographicCamera } {
const engine = new EngineService({ runOutsideAngular: (fn: () => unknown) => fn() } as unknown as NgZone); const zone = { runOutsideAngular: (fn: () => unknown) => fn() } as unknown as NgZone;
const engine = runInInjectionContext(Injector.create({ providers: [{ provide: NgZone, useValue: zone }] }), () => new EngineService());
const perspective = new THREE.PerspectiveCamera(50, 16 / 9, 0.1, 1000); const perspective = new THREE.PerspectiveCamera(50, 16 / 9, 0.1, 1000);
const orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000); const orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
// The two cameras are private, because nothing outside should choose between them by hand. // The two cameras are private, because nothing outside should choose between them by hand.
+2 -3
View File
@@ -1,4 +1,4 @@
import { Injectable, NgZone } from '@angular/core'; import { inject, Injectable, NgZone } from '@angular/core';
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
export type EngineTickCallback = (deltaSeconds: number, elapsedSeconds: number) => void; export type EngineTickCallback = (deltaSeconds: number, elapsedSeconds: number) => void;
@@ -20,6 +20,7 @@ export type SceneCamera = THREE.PerspectiveCamera | THREE.OrthographicCamera;
*/ */
@Injectable() @Injectable()
export class EngineService { export class EngineService {
private readonly ngZone = inject(NgZone);
private readonly clock = new THREE.Clock(false); private readonly clock = new THREE.Clock(false);
private readonly tickCallbacks = new Set<EngineTickCallback>(); private readonly tickCallbacks = new Set<EngineTickCallback>();
@@ -36,8 +37,6 @@ export class EngineService {
private projection: Projection = 'perspective'; private projection: Projection = 'perspective';
private running = false; private running = false;
constructor(private readonly ngZone: NgZone) {}
get isInitialized(): boolean { get isInitialized(): boolean {
return !!this.renderer; return !!this.renderer;
} }
@@ -1,4 +1,4 @@
import { AfterViewInit, Component, ElementRef, OnDestroy, signal, viewChild } from '@angular/core'; import { AfterViewInit, Component, ElementRef, inject, OnDestroy, signal, viewChild } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
@@ -65,6 +65,12 @@ const GLOW_SCALE = 2.6;
` `
}) })
export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
private readonly engine = inject(EngineService);
private readonly dataLoader = inject(DataLoaderService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly navigationStore = inject(NavigationStore);
/** A kept place, revisited from this page: a star means leaving it for the map. */ /** A kept place, revisited from this page: a star means leaving it for the map. */
goToBookmark(bookmark: Bookmark): void { goToBookmark(bookmark: Bookmark): void {
if (bookmark.kind === 'star') { if (bookmark.kind === 'star') {
@@ -95,14 +101,6 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
readonly viewModel = signal<BodyDetailViewModel | undefined>(undefined); readonly viewModel = signal<BodyDetailViewModel | undefined>(undefined);
readonly notFound = signal(false); readonly notFound = signal(false);
constructor(
private readonly engine: EngineService,
private readonly dataLoader: DataLoaderService,
private readonly route: ActivatedRoute,
private readonly router: Router,
private readonly navigationStore: NavigationStore
) {}
ngAfterViewInit(): void { ngAfterViewInit(): void {
void this.bootstrap(); void this.bootstrap();
} }
@@ -273,7 +271,6 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
scene.add(sunLight); scene.add(sunLight);
const geometry = new THREE.SphereGeometry(1, 64, 48); const geometry = new THREE.SphereGeometry(1, 64, 48);
const viewModel = this.viewModel();
this.planetMaterial = new THREE.MeshStandardMaterial({ this.planetMaterial = new THREE.MeshStandardMaterial({
// White, always: the map that arrives a moment later carries the colour, whether it is a // White, always: the map that arrives a moment later carries the colour, whether it is a
// photograph or a surface derived from the body's own measurements. // photograph or a surface derived from the body's own measurements.
@@ -98,6 +98,8 @@ import { ReadoutSectionsComponent } from './readout-sections.component';
export class InfoPanelComponent { export class InfoPanelComponent {
readonly body = input.required<BodyDetailViewModel>(); readonly body = input.required<BodyDetailViewModel>();
private readonly router = inject(Router);
readonly bookmarks = inject(BookmarksStore); readonly bookmarks = inject(BookmarksStore);
private readonly articles = inject(ArticleService); private readonly articles = inject(ArticleService);
@@ -105,7 +107,7 @@ export class InfoPanelComponent {
readonly article = signal<Article | null>(null); readonly article = signal<Article | null>(null);
readonly aboutState = signal<'idle' | 'loading' | 'none' | 'unavailable'>('idle'); readonly aboutState = signal<'idle' | 'loading' | 'none' | 'unavailable'>('idle');
constructor(private readonly router: Router) { constructor() {
// The panel is reused as the route's parameter changes, so what was asked about one body // 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. // must not still be showing under the next one's name.
effect(() => { effect(() => {
@@ -604,7 +604,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
} }
it('links the stars being drawn, and asks again once a new set of them holds still', async () => { it('links the stars being drawn, and asks again once a new set of them holds still', async () => {
const links = vi.fn((_rangePc: number, _drawn: Uint32Array) => Promise.resolve(new Float32Array(0))); const links = vi.fn<LinkScene['routing']['links']>(() => Promise.resolve(new Float32Array(0)));
const component = linkScene(links); const component = linkScene(links);
await settle(); await settle();
expect(links).toHaveBeenCalledTimes(1); expect(links).toHaveBeenCalledTimes(1);
@@ -625,7 +625,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
}); });
it('asks for as much of the graph as a million pixels of line make, around where the view is centred', async () => { it('asks for as much of the graph as a million pixels of line make, around where the view is centred', async () => {
const links = vi.fn((_rangePc: number, _drawn: Uint32Array, _budget?: LinkBudget) => Promise.resolve(new Float32Array(0))); const links = vi.fn<LinkScene['routing']['links']>(() => Promise.resolve(new Float32Array(0)));
Object.defineProperty((fixture.nativeElement as HTMLElement).querySelector('canvas')!, 'clientHeight', { value: 1080 }); Object.defineProperty((fixture.nativeElement as HTMLElement).querySelector('canvas')!, 'clientHeight', { value: 1080 });
// A screen scaled to 200%: 1080 CSS pixels are 2160 drawn ones, and the lines are drawn in those. // A screen scaled to 200%: 1080 CSS pixels are 2160 drawn ones, and the lines are drawn in those.
engine.pixelRatio = 2; engine.pixelRatio = 2;
@@ -641,7 +641,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
it('asks again once the view has zoomed past the budget it asked with, though the drawn stars are the same', async () => { it('asks again once the view has zoomed past the budget it asked with, though the drawn stars are the same', async () => {
// All three stars fit the star budget, so the drawn set never changes: only the budget can. // All three stars fit the star budget, so the drawn set never changes: only the budget can.
const links = vi.fn((_rangePc: number, _drawn: Uint32Array, _budget?: LinkBudget) => Promise.resolve(new Float32Array(0))); const links = vi.fn<LinkScene['routing']['links']>(() => Promise.resolve(new Float32Array(0)));
Object.defineProperty((fixture.nativeElement as HTMLElement).querySelector('canvas')!, 'clientHeight', { value: 1080 }); Object.defineProperty((fixture.nativeElement as HTMLElement).querySelector('canvas')!, 'clientHeight', { value: 1080 });
const component = linkScene(links); const component = linkScene(links);
await advanceFrames(engine, 0.3); await advanceFrames(engine, 0.3);
@@ -658,7 +658,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
}); });
it('asks for no graph from inside a system, where distances are in astronomical units', async () => { it('asks for no graph from inside a system, where distances are in astronomical units', async () => {
const links = vi.fn((_rangePc: number, _drawn: Uint32Array, _budget?: LinkBudget) => Promise.resolve(new Float32Array(0))); const links = vi.fn<LinkScene['routing']['links']>(() => Promise.resolve(new Float32Array(0)));
navigationStore.selectStar(SUN.id); navigationStore.selectStar(SUN.id);
await flushAsync(); await flushAsync();
await advanceFrames(engine, 2.5); await advanceFrames(engine, 2.5);
@@ -695,7 +695,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
}); });
it('gives a view on the move a new graph at least every quarter second, rather than waiting for it to stop', async () => { it('gives a view on the move a new graph at least every quarter second, rather than waiting for it to stop', async () => {
const links = vi.fn((_rangePc: number, _drawn: Uint32Array) => Promise.resolve(new Float32Array(0))); const links = vi.fn<LinkScene['routing']['links']>(() => Promise.resolve(new Float32Array(0)));
const component = linkScene(links); const component = linkScene(links);
await settle(); await settle();
@@ -1,4 +1,4 @@
import { AfterViewInit, Component, computed, effect, ElementRef, OnDestroy, signal, viewChild } from '@angular/core'; import { AfterViewInit, Component, computed, effect, ElementRef, inject, OnDestroy, signal, viewChild } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
@@ -323,6 +323,11 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
` `
}) })
export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private readonly engine = inject(EngineService);
private readonly dataLoader = inject(DataLoaderService);
private readonly router = inject(Router);
readonly navigationStore = inject(NavigationStore);
private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas'); private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
private readonly labelHostRef = viewChild.required<ElementRef<HTMLDivElement>>('labelHost'); private readonly labelHostRef = viewChild.required<ElementRef<HTMLDivElement>>('labelHost');
private readonly leaderRef = viewChild.required<ElementRef<SVGLineElement>>('leader'); private readonly leaderRef = viewChild.required<ElementRef<SVGLineElement>>('leader');
@@ -467,12 +472,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private starMarker?: THREE.Mesh; private starMarker?: THREE.Mesh;
private starGlow?: THREE.Sprite; private starGlow?: THREE.Sprite;
constructor( constructor() {
private readonly engine: EngineService,
private readonly dataLoader: DataLoaderService,
private readonly router: Router,
readonly navigationStore: NavigationStore
) {
effect(() => { effect(() => {
const selectedStarId = this.navigationStore.selectedStarId(); const selectedStarId = this.navigationStore.selectedStarId();
if (this.ready) { if (this.ready) {
@@ -484,7 +484,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// Reads both signals, so flipping the layer on and dragging the range each land here. // Reads both signals, so flipping the layer on and dragging the range each land here.
effect(() => { effect(() => {
this.jumpRangePc(); this.jumpRangePc();
this.display().jumpLinks; void this.display().jumpLinks;
this.scheduleJumpLinks(); this.scheduleJumpLinks();
}); });
} }
@@ -1804,8 +1804,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
} }
private swapToSystemSpace(star: StarRecord, direction: THREE.Vector3, onComplete: () => void): void { private swapToSystemSpace(star: StarRecord, direction: THREE.Vector3, onComplete: () => void): void {
const camera = this.engine.getCamera();
this.systemRenderer?.dispose(); this.systemRenderer?.dispose();
if (this.starMarker) { if (this.starMarker) {
this.systemGroup.remove(this.starMarker); this.systemGroup.remove(this.starMarker);
@@ -1912,7 +1910,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
} }
private swapToGalaxySpace(exitingStarId: number, direction: THREE.Vector3, isSwitchingSystems: boolean, onComplete: () => void): void { private swapToGalaxySpace(exitingStarId: number, direction: THREE.Vector3, isSwitchingSystems: boolean, onComplete: () => void): void {
const camera = this.engine.getCamera();
const star = this.starsById.get(exitingStarId); const star = this.starsById.get(exitingStarId);
const starPc = star ? new THREE.Vector3(star.x, star.y, star.z) : GALAXY_OVERVIEW_TARGET.clone(); const starPc = star ? new THREE.Vector3(star.x, star.y, star.z) : GALAXY_OVERVIEW_TARGET.clone();
+1 -1
View File
@@ -1,6 +1,6 @@
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
import { GALACTIC_BASIS_EQUATORIAL, SUN_HEIGHT_ABOVE_MIDPLANE_PC } from '../../shared/astro/galaxy'; import { GALACTIC_BASIS_EQUATORIAL } from '../../shared/astro/galaxy';
const SEGMENTS_PER_RING = 180; const SEGMENTS_PER_RING = 180;
@@ -87,9 +87,9 @@ export function createRandom(seed: number): () => number {
/** Standard normal sample, by the polar form of Box-Muller. */ /** Standard normal sample, by the polar form of Box-Muller. */
function gaussian(random: () => number): number { function gaussian(random: () => number): number {
let u = 0; let u: number;
let v = 0; let v: number;
let s = 0; let s: number;
do { do {
u = random() * 2 - 1; u = random() * 2 - 1;
v = random() * 2 - 1; v = random() * 2 - 1;
@@ -5,7 +5,7 @@ import { BrightnessIndex, brightnessIndex, Positioned } from '../../shared/astro
import { spectralTypeToColorIndex } from '../../shared/astro/spectral'; import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
import { SceneCamera } from '../../core/engine/engine.service'; import { SceneCamera } from '../../core/engine/engine.service';
import { StarRecord } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size'; import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES } from './angular-size';
/** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */ /** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */
const MIN_POINT_SIZE = 1.5; const MIN_POINT_SIZE = 1.5;
+6 -6
View File
@@ -1,4 +1,4 @@
import { Component, computed, ElementRef, output, signal, viewChild } from '@angular/core'; import { Component, computed, ElementRef, inject, output, signal, viewChild } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { DataLoaderService } from '../../core/data/data-loader.service'; import { DataLoaderService } from '../../core/data/data-loader.service';
@@ -80,6 +80,10 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
` `
}) })
export class SearchComponent { export class SearchComponent {
private readonly dataLoader = inject(DataLoaderService);
private readonly navigationStore = inject(NavigationStore);
private readonly router = inject(Router);
readonly query = signal(''); readonly query = signal('');
/** Fires once a result has been chosen and navigation kicked off — the dock uses it to hand /** Fires once a result has been chosen and navigation kicked off — the dock uses it to hand
* the view back to the readout, since the thing to look at is now the scene, not the box. */ * the view back to the readout, since the thing to look at is now the scene, not the box. */
@@ -109,11 +113,7 @@ export class SearchComponent {
readonly matchTotal = computed(() => this.matches().length); readonly matchTotal = computed(() => this.matches().length);
readonly results = computed(() => this.matches().slice(0, MAX_RESULTS)); readonly results = computed(() => this.matches().slice(0, MAX_RESULTS));
constructor( constructor() {
private readonly dataLoader: DataLoaderService,
private readonly navigationStore: NavigationStore,
private readonly router: Router
) {
void this.buildIndex(); void this.buildIndex();
} }
+2 -1
View File
@@ -168,7 +168,8 @@ describe('minimumRangeBetween', () => {
expect(minimumRangeBetween(knotAndChain(), 0, CHAIN_END, 8).rangePc).toBeCloseTo(1, 1); expect(minimumRangeBetween(knotAndChain(), 0, CHAIN_END, 8).rangePc).toBeCloseTo(1, 1);
}); });
it('stops bisecting where a search gave up, and hands back a range that does work', { timeout: 30_000 }, () => { // 7,5 s sur un poste récent, autour de 30 s sur le runner Gitea du VPS : 90 s de marge.
it('stops bisecting where a search gave up, and hands back a range that does work', { timeout: 90_000 }, () => {
// Below the chain's own hop of a parsec, the crowd is still one connected piece and larger than // Below the chain's own hop of a parsec, the crowd is still one connected piece and larger than
// the budget, so those probes give up. Reading a give-up as "no chain at this range" is what // the budget, so those probes give up. Reading a give-up as "no chain at this range" is what
// used to report ranges up to 29% wider than needed, and went on paying for probes whose // used to report ranges up to 29% wider than needed, and went on paying for probes whose
@@ -115,11 +115,12 @@ describe('renderPlanetTexture', () => {
it('produces no NaN or out-of-range bytes for any class', () => { it('produces no NaN or out-of-range bytes for any class', () => {
for (const planetClass of ALL_CLASSES) { for (const planetClass of ALL_CLASSES) {
const pixels = renderPlanetTexture(appearanceOf(planetClass, { polarCapExtentDeg: 30 }), SIZE); const pixels = renderPlanetTexture(appearanceOf(planetClass, { polarCapExtentDeg: 30 }), SIZE);
for (const value of pixels) { // One assertion per class, not three per byte: 245 760 expect() calls ran past the
expect(Number.isInteger(value)).toBe(true); // default 5 s timeout on a slower CI machine.
expect(value).toBeGreaterThanOrEqual(0); const firstBad = pixels.findIndex(
expect(value).toBeLessThanOrEqual(255); (value) => !Number.isInteger(value) || value < 0 || value > 255,
} );
expect(firstBad, planetClass).toBe(-1);
} }
}); });
}); });
+1 -1
View File
@@ -82,7 +82,7 @@ export class BookmarksStore {
} }
private read(): Bookmark[] { private read(): Bookmark[] {
let raw: string | null = null; let raw: string | null;
try { try {
raw = localStorage.getItem(STORAGE_KEY); raw = localStorage.getItem(STORAGE_KEY);
} catch { } catch {
+2 -17
View File
@@ -1,24 +1,9 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
/* Compiler and Angular options come from @avalon-vanguard/config (tsconfig/angular.json). */
{ {
"extends": "@avalon-vanguard/config/tsconfig/angular.json",
"compileOnSave": false, "compileOnSave": false,
"compilerOptions": {
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true
},
"files": [], "files": [],
"references": [ "references": [
{ {