mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge branch 'main' into fix/recent-imagery-qa-relative-layout-20261003
This commit is contained in:
commit
cb2ead3c16
17 files changed
+257
-16
No files matched your search
@@ -150,7 +150,7 @@ jobs:
|
|||||||
run: npm run check:boundaries
|
run: npm run check:boundaries
|
||||||
|
|
||||||
- name: Run focused onboarding tests
|
- name: Run focused onboarding tests
|
||||||
run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/keySetupHardening.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs
|
run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/keySetupHardening.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs src/codexOauthRealtime.test.mjs
|
||||||
|
|
||||||
- name: Build production bundle
|
- name: Build production bundle
|
||||||
run: npm run build
|
run: npm run build
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
- Snow flakes sway within a fixed range instead of drifting sideways faster the
|
||||||
|
longer the app runs; the Wind setting still scales the sway.
|
||||||
|
|
||||||
|
- The right rail ignores panels that other CSS hides, such as during an event
|
||||||
|
view, so they no longer force one-panel-at-a-time layout or collapse visible
|
||||||
|
panels. Panels the rail collapses itself are still restored.
|
||||||
|
|
||||||
|
- Coordinate searches refuse empty latitude or longitude components instead of navigating to an unintended location.
|
||||||
|
|
||||||
- Bing Aerial and Bing Labels request HTTPS tiles on HTTP localhost through
|
- Bing Aerial and Bing Labels request HTTPS tiles on HTTP localhost through
|
||||||
their ion-backed providers, retaining ion and per-tile attribution without
|
their ion-backed providers, retaining ion and per-tile attribution without
|
||||||
widening the Content Security Policy.
|
widening the Content Security Policy.
|
||||||
@@ -193,6 +202,12 @@
|
|||||||
largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed
|
largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed
|
||||||
under NLOD 2.0.
|
under NLOD 2.0.
|
||||||
|
|
||||||
|
- Live HLS cameras play on a page opened over plain HTTP from another machine
|
||||||
|
(`HOST=0.0.0.0`). The client lease id came from `crypto.randomUUID`, which
|
||||||
|
browsers only expose in secure contexts, so starting a live feed threw
|
||||||
|
before playback. The id now falls back to a v4 UUID built from
|
||||||
|
`crypto.getRandomValues`, which the server's lease check accepts.
|
||||||
|
|
||||||
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
|
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
|
||||||
|
|
||||||
- Ship each bundled data pack once. The region, marine, admin-boundary,
|
- Ship each bundled data pack once. The region, marine, admin-boundary,
|
||||||
|
|||||||
@@ -117,6 +117,14 @@ locked dependencies, finds a free local port, and opens the app.
|
|||||||
Version 8.2 fixes the launcher installation issue;
|
Version 8.2 fixes the launcher installation issue;
|
||||||
[details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g).
|
[details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g).
|
||||||
|
|
||||||
|
**Pinokio opens but the globe is very choppy?** Before treating that as a GPU
|
||||||
|
compatibility or minimum-hardware problem, copy the local server URL shown by
|
||||||
|
Pinokio and open the same URL in your normal hardware-accelerated browser. One
|
||||||
|
Pinokio 8.2.0 user reported severe stutter in Pinokio's embedded view that went
|
||||||
|
away when the same running server was opened in an external browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)).
|
||||||
|
That points to the embedded browser rather than the app server or your
|
||||||
|
hardware; it does not establish a minimum GPU requirement.
|
||||||
|
|
||||||
### Path 2 — Terminal / coding agent
|
### Path 2 — Terminal / coding agent
|
||||||
|
|
||||||
Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about
|
Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about
|
||||||
|
|||||||
+1
-1
@@ -158,7 +158,7 @@ routing and regional caches. Request parameters cannot override these endpoints.
|
|||||||
Voice controls accept an action runner and a controller factory through
|
Voice controls accept an action runner and a controller factory through
|
||||||
`createVoiceCommands` (`./voice/commands`). The default Realtime controller owns
|
`createVoiceCommands` (`./voice/commands`). The default Realtime controller owns
|
||||||
microphone tracks, playback, push-to-talk, tool cancellation and radio handoff.
|
microphone tracks, playback, push-to-talk, tool cancellation and radio handoff.
|
||||||
Its backend supplies `requestToken({ tier, signal })` and
|
Its backend supplies `requestToken({ tier, authMode, signal })` and
|
||||||
`negotiate({ offerSdp, credential, signal })`. The Realtime-compatible adapter
|
`negotiate({ offerSdp, credential, signal })`. The Realtime-compatible adapter
|
||||||
(`./voice/realtime-backend`) accepts separate token and connection transports
|
(`./voice/realtime-backend`) accepts separate token and connection transports
|
||||||
and endpoints. Only the short-lived client secret reaches the SDP endpoint.
|
and endpoints. Only the short-lived client secret reaches the SDP endpoint.
|
||||||
|
|||||||
+10
-1
@@ -1013,6 +1013,8 @@ and sharpen. Display supplies actions and renders settings; effect execution has
|
|||||||
no DOM dependency. Existing presets and the 500 ms transition remain unchanged.
|
no DOM dependency. Existing presets and the 500 ms transition remain unchanged.
|
||||||
Stopping the controller revokes animation before asynchronous UI teardown; final
|
Stopping the controller revokes animation before asynchronous UI teardown; final
|
||||||
destruction removes its stages and restores the borrowed bloom configuration.
|
destruction removes its stages and restores the borrowed bloom configuration.
|
||||||
|
Snow's sideways sway stays within a fixed range at any session age; Wind scales
|
||||||
|
its amplitude.
|
||||||
|
|
||||||
## Display control ownership
|
## Display control ownership
|
||||||
|
|
||||||
@@ -1047,6 +1049,9 @@ Automatic collapse remains presentation only, prioritizes the latest explicit
|
|||||||
panel, and honors keyboard focus on the right. Mobile layout still releases
|
panel, and honors keyboard focus on the right. Mobile layout still releases
|
||||||
desktop allocation styles. Stable Display allocation avoids unnecessary style
|
desktop allocation styles. Stable Display allocation avoids unnecessary style
|
||||||
writes. This extraction does not change panel positions or layout defaults.
|
writes. This extraction does not change panel positions or layout defaults.
|
||||||
|
The right rail skips panels hidden by other CSS (computed `display: none`), but
|
||||||
|
keeps the collapsed panels its own exclusive presentation hides, so automatic
|
||||||
|
collapse can still be undone.
|
||||||
|
|
||||||
## Surface keyboard lifecycle
|
## Surface keyboard lifecycle
|
||||||
|
|
||||||
@@ -4320,6 +4325,8 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
|
|
||||||
### Place-search providers
|
### Place-search providers
|
||||||
|
|
||||||
|
Coordinate input requires exactly two nonempty components; extra comma or semicolon separators decline to geocoding rather than silently choosing a location.
|
||||||
|
|
||||||
Location search/fly-to, annotations and Radio location lookup receive one
|
Location search/fly-to, annotations and Radio location lookup receive one
|
||||||
`placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes
|
`placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes
|
||||||
two offline providers first — a strict decimal-degree coordinate reader and an
|
two offline providers first — a strict decimal-degree coordinate reader and an
|
||||||
@@ -5220,7 +5227,9 @@ its paint loop. Camera deactivation destroys the decoder, and fatal playback
|
|||||||
errors revert to the labeled frame fallback. Camera changes clear the old panel
|
errors revert to the labeled frame fallback. Camera changes clear the old panel
|
||||||
frame. hls.js is imported only when an HLS camera starts. Two live sessions may
|
frame. hls.js is imported only when an HLS camera starts. Two live sessions may
|
||||||
coexist with at most eight independent consumer leases each; no-access lease
|
coexist with at most eight independent consumer leases each; no-access lease
|
||||||
expiry is 15 seconds. Closing one consumer leaves other consumers running. Encrypted/fMP4/byte-range playlists fail
|
expiry is 15 seconds. Closing one consumer leaves other consumers running. Lease
|
||||||
|
ids use `crypto.randomUUID` where the page is a secure context and an equivalent
|
||||||
|
v4 id from `crypto.getRandomValues` on plain-HTTP LAN pages. Encrypted/fMP4/byte-range playlists fail
|
||||||
closed. Performance and live agency reliability require recorded validation.
|
closed. Performance and live agency reliability require recorded validation.
|
||||||
|
|
||||||
## Wind forecast models
|
## Wind forecast models
|
||||||
|
|||||||
@@ -128,6 +128,12 @@ Use the same controls before attributing a difference to the application:
|
|||||||
6. Record live object counts before attributing a difference to the client.
|
6. Record live object counts before attributing a difference to the client.
|
||||||
7. Treat a live-source outage as missing coverage, not as evidence of low client
|
7. Treat a live-source outage as missing coverage, not as evidence of low client
|
||||||
rendering cost.
|
rendering cost.
|
||||||
|
8. Record whether the app is running in Pinokio's embedded view or in an
|
||||||
|
external browser. One Pinokio 8.2.0 user reported severe stutter in the
|
||||||
|
embedded view that went away when the same local URL was opened in a normal
|
||||||
|
browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)).
|
||||||
|
Do not compare embedded-view and external-browser measurements directly;
|
||||||
|
compare the reported GPU renderer and matched scene settings first.
|
||||||
|
|
||||||
## What is not established yet
|
## What is not established yet
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ The session adapter, action runner and backend interfaces remain unchanged.
|
|||||||
| Owner | State and responsibility |
|
| Owner | State and responsibility |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `realtimeConnection.js` | Start generation, request cancellation, peer/data channel, acquired microphone stream and playback element |
|
| `realtimeConnection.js` | Start generation, request cancellation, peer/data channel, acquired microphone stream and playback element |
|
||||||
|
| `realtimeBackend.js` | Token and SDP requests, their separate transports, the abort/timeout scope around both, and client-secret expiry |
|
||||||
| `realtimeTurns.js` | Active and superseded responses, pending confirmations, deduplicated calls and action cancellation |
|
| `realtimeTurns.js` | Active and superseded responses, pending confirmations, deduplicated calls and action cancellation |
|
||||||
| `realtimeRadio.js` | Speaker ducking, prepared playback, stronger-action reservations and playback observers |
|
| `realtimeRadio.js` | Speaker ducking, prepared playback, stronger-action reservations and playback observers |
|
||||||
| `realtimeInput.js` | Physical Space gesture, microphone mute, input/output meters and their frame/listener lifetime |
|
| `realtimeInput.js` | Physical Space gesture, microphone mute, input/output meters and their frame/listener lifetime |
|
||||||
@@ -21,6 +22,15 @@ The session adapter, action runner and backend interfaces remain unchanged.
|
|||||||
| `resultDisplay.js` | What the voice card shows for a result and which items it numbers (read by the card and `referents.js`) |
|
| `resultDisplay.js` | What the voice card shows for a result and which items it numbers (read by the card and `referents.js`) |
|
||||||
| `gevActions.js` runner | Analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) |
|
| `gevActions.js` runner | Analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) |
|
||||||
|
|
||||||
|
The backend is replaceable per deployment: `realtimeController.js` accepts it as a
|
||||||
|
`backend` option and falls back to `createRealtimeBackend()`. Its two method
|
||||||
|
signatures live with [application construction](APPLICATION.md); what belongs here
|
||||||
|
is the ownership. The connection is the backend's only consumer in the application.
|
||||||
|
The connection reads credential metadata for diagnostics and cost accounting,
|
||||||
|
then passes the complete credential unchanged to `negotiate`. Client-secret expiry is refused inside the backend, not by the connection.
|
||||||
|
`realtimeOwners.test.mjs` drives whole sessions through a substitute that
|
||||||
|
implements only those two methods.
|
||||||
|
|
||||||
Pure input policy, preferences and protocol-response policy have separate modules.
|
Pure input policy, preferences and protocol-response policy have separate modules.
|
||||||
Owners receive named readers, operations or focused collaborators. Cross-owner
|
Owners receive named readers, operations or focused collaborators. Cross-owner
|
||||||
cancellation uses operations; response handling does not own Radio's reservations
|
cancellation uses operations; response handling does not own Radio's reservations
|
||||||
|
|||||||
@@ -21,9 +21,20 @@ export function assertNode24AllocationRuntime(version = process.versions.node) {
|
|||||||
return version;
|
return version;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directories this runner searches for `*.test.mjs`, in stable order.
|
||||||
|
*
|
||||||
|
* `server/` belongs here because provider modules own tests of their own: a
|
||||||
|
* suite placed beside the module it covers must run, and CI's unit-test job runs
|
||||||
|
* `npm test`, so a directory missing from this list is a suite that never runs
|
||||||
|
* there. `unitTestRunner.test.mjs` asserts the list covers every `*.test.mjs`
|
||||||
|
* outside dependencies, build output and dot-directories, so adding a tree
|
||||||
|
* without adding it here fails.
|
||||||
|
*/
|
||||||
|
export const UNIT_TEST_ROOTS = Object.freeze(['src', 'server']);
|
||||||
|
|
||||||
/** Discover repository unit tests in stable path order. */
|
/** Discover repository unit tests in stable path order. */
|
||||||
export function discoverUnitTestFiles(root = process.cwd()) {
|
export function discoverUnitTestFiles(root = process.cwd()) {
|
||||||
const sourceRoot = path.join(root, 'src');
|
|
||||||
const files = [];
|
const files = [];
|
||||||
const visit = (directory) => {
|
const visit = (directory) => {
|
||||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||||
@@ -34,7 +45,7 @@ export function discoverUnitTestFiles(root = process.cwd()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
visit(sourceRoot);
|
for (const testRoot of UNIT_TEST_ROOTS) visit(path.join(root, testRoot));
|
||||||
return files.sort();
|
return files.sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -145,17 +145,20 @@ test('Codex OAuth reader enforces expiry margin and bounds tokens without exp by
|
|||||||
|
|
||||||
test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => {
|
test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => {
|
||||||
const checked = [];
|
const checked = [];
|
||||||
|
// Built with path.join so the stub matches the resolver on Windows too,
|
||||||
|
// where the separators are backslashes.
|
||||||
|
const installed = path.join('/home/fixture', '.local', 'bin', 'codex');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveCodexExecutable({
|
resolveCodexExecutable({
|
||||||
environment: {},
|
environment: {},
|
||||||
home: '/home/fixture',
|
home: '/home/fixture',
|
||||||
access(candidate) {
|
access(candidate) {
|
||||||
checked.push(candidate);
|
checked.push(candidate);
|
||||||
if (candidate.endsWith('/.local/bin/codex')) return;
|
if (candidate === installed) return;
|
||||||
throw new Error('missing');
|
throw new Error('missing');
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
path.join('/home/fixture', '.local', 'bin', 'codex'),
|
installed,
|
||||||
);
|
);
|
||||||
assert.equal(checked.length, 1);
|
assert.equal(checked.length, 1);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* A v4 UUID for the client lease. `crypto.randomUUID` only exists in secure
|
||||||
|
* contexts, so a page opened over plain HTTP from another machine (the
|
||||||
|
* HOST=0.0.0.0 LAN opt-in) has none; `getRandomValues` exists in every context
|
||||||
|
* and yields the same shape the server's lease check accepts.
|
||||||
|
*/
|
||||||
|
export function createLeaseId(cryptoImpl = globalThis.crypto) {
|
||||||
|
if (typeof cryptoImpl.randomUUID === 'function') {
|
||||||
|
return cryptoImpl.randomUUID();
|
||||||
|
}
|
||||||
|
const bytes = cryptoImpl.getRandomValues(new Uint8Array(16));
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0'));
|
||||||
|
return [
|
||||||
|
hex.slice(0, 4),
|
||||||
|
hex.slice(4, 6),
|
||||||
|
hex.slice(6, 8),
|
||||||
|
hex.slice(8, 10),
|
||||||
|
hex.slice(10),
|
||||||
|
]
|
||||||
|
.map((group) => group.join(''))
|
||||||
|
.join('-');
|
||||||
|
}
|
||||||
|
|
||||||
/** One decoder per active camera; both surfaces consume this video element. */
|
/** One decoder per active camera; both surfaces consume this video element. */
|
||||||
export function attachCctvVideo(
|
export function attachCctvVideo(
|
||||||
video,
|
video,
|
||||||
@@ -7,13 +32,14 @@ export function attachCctvVideo(
|
|||||||
loadHls = () => import('hls.js'),
|
loadHls = () => import('hls.js'),
|
||||||
onFailure = () => {},
|
onFailure = () => {},
|
||||||
fetchImpl = globalThis.fetch,
|
fetchImpl = globalThis.fetch,
|
||||||
|
cryptoImpl = globalThis.crypto,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
// Preserve replay for existing finite video feeds; live HLS must not loop.
|
// Preserve replay for existing finite video feeds; live HLS must not loop.
|
||||||
video.loop = feedType !== 'hls';
|
video.loop = feedType !== 'hls';
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let hls = null;
|
let hls = null;
|
||||||
const leaseId = feedType === 'hls' ? globalThis.crypto.randomUUID() : null;
|
const leaseId = feedType === 'hls' ? createLeaseId(cryptoImpl) : null;
|
||||||
const mediaUrl =
|
const mediaUrl =
|
||||||
feedType === 'hls'
|
feedType === 'hls'
|
||||||
? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}`
|
? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}`
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { attachCctvVideo } from './videoPlayback.js';
|
import { webcrypto } from 'node:crypto';
|
||||||
|
import { attachCctvVideo, createLeaseId } from './videoPlayback.js';
|
||||||
|
|
||||||
|
const LEASE_V4 =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
|
|
||||||
|
/** What an insecure context exposes: getRandomValues but no randomUUID. */
|
||||||
|
const insecureCrypto = {
|
||||||
|
getRandomValues: (array) => webcrypto.getRandomValues(array),
|
||||||
|
};
|
||||||
function video() {
|
function video() {
|
||||||
const v = new EventTarget();
|
const v = new EventTarget();
|
||||||
Object.assign(v, {
|
Object.assign(v, {
|
||||||
@@ -69,6 +78,34 @@ test('native HLS releases its client lease without response-header access', asyn
|
|||||||
assert.equal(releases[0].init.method, 'DELETE');
|
assert.equal(releases[0].init.method, 'DELETE');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('lease ids are v4 UUIDs with or without crypto.randomUUID', () => {
|
||||||
|
assert.match(createLeaseId(), LEASE_V4);
|
||||||
|
const ids = new Set();
|
||||||
|
for (let i = 0; i < 64; i++) ids.add(createLeaseId(insecureCrypto));
|
||||||
|
assert.equal(ids.size, 64);
|
||||||
|
for (const id of ids) assert.match(id, LEASE_V4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HLS starts on a plain-HTTP LAN page without crypto.randomUUID', async () => {
|
||||||
|
const source = video();
|
||||||
|
source.canPlayType = () => 'probably';
|
||||||
|
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
|
||||||
|
loadHls: async () => ({ default: { isSupported: () => false } }),
|
||||||
|
fetchImpl: async () => {},
|
||||||
|
cryptoImpl: insecureCrypto,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await playback.ready;
|
||||||
|
const lease = new URL(
|
||||||
|
source.src,
|
||||||
|
'http://192.168.1.10:4173/',
|
||||||
|
).searchParams.get('lease');
|
||||||
|
assert.match(lease, LEASE_V4);
|
||||||
|
} finally {
|
||||||
|
playback.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('finite video feeds retain looping while live HLS does not loop', async () => {
|
test('finite video feeds retain looping while live HLS does not loop', async () => {
|
||||||
for (const feedType of ['mp4', 'webm', 'hls']) {
|
for (const feedType of ['mp4', 'webm', 'hls']) {
|
||||||
const source = video();
|
const source = video();
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { parseCoordinateQuery } from './coordinateParser.js';
|
||||||
|
test('search refuses omitted components and repeated separators', () => {
|
||||||
|
for (const q of ['30,, -97', ',30,-97', '30,-97,', '30;; -97', '30,; -97'])
|
||||||
|
assert.equal(parseCoordinateQuery(q), null, q);
|
||||||
|
for (const q of ['30,-97', '30; -97', '30 -97', 'N 30, W 97'])
|
||||||
|
assert.deepEqual(
|
||||||
|
[parseCoordinateQuery(q).lat, parseCoordinateQuery(q).lon],
|
||||||
|
[30, -97],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -49,8 +49,8 @@ function splitComponents(query) {
|
|||||||
const parts = /[,;]/.test(text)
|
const parts = /[,;]/.test(text)
|
||||||
? text.split(/[,;]/)
|
? text.split(/[,;]/)
|
||||||
: text.split(/\s+(?=[+-]?[.\d]|[NSEW]\s*[+-]?[.\d])/i);
|
: text.split(/\s+(?=[+-]?[.\d]|[NSEW]\s*[+-]?[.\d])/i);
|
||||||
const trimmed = parts.map((part) => part.trim()).filter(Boolean);
|
const trimmed = parts.map((part) => part.trim());
|
||||||
return trimmed.length === 2 ? trimmed : null;
|
return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Present a parsed pair the way a coordinate is normally written. */
|
/** Present a parsed pair the way a coordinate is normally written. */
|
||||||
|
|||||||
+4
-2
@@ -29,11 +29,13 @@ export const snowShader = {
|
|||||||
float depth = 0.5 + layer * 0.5;
|
float depth = 0.5 + layer * 0.5;
|
||||||
float speed = 0.4 + layer * 0.3;
|
float speed = 0.4 + layer * 0.3;
|
||||||
float size = mix(0.01, 0.025, layer);
|
float size = mix(0.01, 0.025, layer);
|
||||||
float windForce = sin(time * 0.5 + layer * 3.14) * (0.05 + wind * 0.2);
|
float windForce = 0.05 + wind * 0.2;
|
||||||
|
|
||||||
vec2 snowUV = uv * vec2(1.0, 0.5) * (4.0 + layer * 4.0);
|
vec2 snowUV = uv * vec2(1.0, 0.5) * (4.0 + layer * 4.0);
|
||||||
snowUV.y += time * speed;
|
snowUV.y += time * speed;
|
||||||
snowUV.x += time * windForce;
|
// Offset is the integral of a swaying velocity sin(time * 0.5 + phase) * windForce,
|
||||||
|
// so the sway stays bounded however long the app has been running.
|
||||||
|
snowUV.x -= 2.0 * cos(time * 0.5 + layer * 3.14) * windForce;
|
||||||
|
|
||||||
vec2 cell = floor(snowUV);
|
vec2 cell = floor(snowUV);
|
||||||
vec2 f = fract(snowUV);
|
vec2 f = fract(snowUV);
|
||||||
|
|||||||
@@ -704,6 +704,51 @@ test('narrow-screen rails pin every hosted panel glow inside its panel box', ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity', () => {
|
||||||
|
const f = fixture('right');
|
||||||
|
const weather = element('weather-panel', { height: 300 });
|
||||||
|
weather.computed.display = 'none';
|
||||||
|
f.stack.children.push(weather);
|
||||||
|
weather.parentElement = f.stack;
|
||||||
|
|
||||||
|
f.run();
|
||||||
|
|
||||||
|
assert.equal(f.stack.classList.contains('layout-exclusive'), false);
|
||||||
|
assert.equal(f.stack.dataset.expandedCount, '0');
|
||||||
|
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
||||||
|
assert.equal(f.second.getAttribute('aria-hidden'), undefined);
|
||||||
|
assert.equal(
|
||||||
|
weather.style.getPropertyValue('--right-panel-allocated-height'),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('right layout still restores panels that exclusive presentation hides', () => {
|
||||||
|
const f = fixture('right');
|
||||||
|
// Model layers.css: an exclusive rail hides its collapsed panels.
|
||||||
|
f.options.windowRef.getComputedStyle = (node) => ({
|
||||||
|
...node.computed,
|
||||||
|
display:
|
||||||
|
f.stack.classList.contains('layout-exclusive') &&
|
||||||
|
node.classList.contains('collapsed')
|
||||||
|
? 'none'
|
||||||
|
: node.computed.display,
|
||||||
|
});
|
||||||
|
f.expand(f.first, 250);
|
||||||
|
f.second.classList.add('layout-auto-collapsed');
|
||||||
|
f.stack.classList.add('layout-exclusive');
|
||||||
|
|
||||||
|
f.run();
|
||||||
|
assert.equal(f.stack.classList.contains('layout-exclusive'), true);
|
||||||
|
assert.equal(f.second.getAttribute('aria-hidden'), 'true');
|
||||||
|
|
||||||
|
f.options.hud.visible = false;
|
||||||
|
f.run();
|
||||||
|
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||||
|
assert.equal(f.second.classList.contains('layout-auto-collapsed'), false);
|
||||||
|
assert.deepEqual(f.collapsed, ['second']);
|
||||||
|
});
|
||||||
|
|
||||||
test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => {
|
test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => {
|
||||||
const f = fixture('right');
|
const f = fixture('right');
|
||||||
const imagery = element('recent-imagery-panel', { height: 0 });
|
const imagery = element('recent-imagery-panel', { height: 0 });
|
||||||
|
|||||||
@@ -69,10 +69,17 @@ export function layoutRightPanelRail({
|
|||||||
// A collapse may schedule one follow-up, which only measures and allocates.
|
// A collapse may schedule one follow-up, which only measures and allocates.
|
||||||
const isCollapseRetry = pendingCollapseRetries.delete(stack);
|
const isCollapseRetry = pendingCollapseRetries.delete(stack);
|
||||||
|
|
||||||
// A floating (lifted-out) panel is not laid out by the rail.
|
// A floating (lifted-out) panel is not laid out by the rail. Neither is one
|
||||||
|
// that other CSS hides, such as an event view. Exclusive presentation hides
|
||||||
|
// collapsed panels itself; those stay in the rail so they can be restored.
|
||||||
|
const hiddenByRail = (panel) =>
|
||||||
|
stack.classList.contains('layout-exclusive') &&
|
||||||
|
panel.classList.contains('collapsed');
|
||||||
const panels = [...stack.children].filter(
|
const panels = [...stack.children].filter(
|
||||||
(panel) =>
|
(panel) =>
|
||||||
panel.matches('[data-panel-id]:not(.panel-floating)') && !panel.hidden,
|
panel.matches('[data-panel-id]:not(.panel-floating)') &&
|
||||||
|
!panel.hidden &&
|
||||||
|
(hiddenByRail(panel) || getComputedStyle(panel).display !== 'none'),
|
||||||
);
|
);
|
||||||
if (!hud.visible || hud.variant !== 'tactical') {
|
if (!hud.visible || hud.variant !== 'tactical') {
|
||||||
for (const panel of panels.filter((item) =>
|
for (const panel of panels.filter((item) =>
|
||||||
|
|||||||
@@ -1,14 +1,40 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import {
|
import {
|
||||||
ALLOCATION_TEST_FILES,
|
ALLOCATION_TEST_FILES,
|
||||||
|
UNIT_TEST_ROOTS,
|
||||||
allocationTestArgs,
|
allocationTestArgs,
|
||||||
assertNode24AllocationRuntime,
|
assertNode24AllocationRuntime,
|
||||||
buildUnitTestPlan,
|
buildUnitTestPlan,
|
||||||
|
discoverUnitTestFiles,
|
||||||
isCalibratedAllocationRuntime,
|
isCalibratedAllocationRuntime,
|
||||||
} from '../scripts/run-unit-tests.mjs';
|
} from '../scripts/run-unit-tests.mjs';
|
||||||
|
|
||||||
|
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
|
||||||
|
/** Every `*.test.mjs` a contributor could add, found without consulting the runner. */
|
||||||
|
function testFilesOnDisk(root) {
|
||||||
|
// Build outputs and dependencies are not places a contributor writes a test,
|
||||||
|
// and walking them would be slow enough to matter. Everything else counts.
|
||||||
|
const skip = new Set(['node_modules', 'dist', 'build', 'public']);
|
||||||
|
const found = [];
|
||||||
|
const visit = (directory) => {
|
||||||
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||||
|
if (entry.name.startsWith('.') || skip.has(entry.name)) continue;
|
||||||
|
const absolute = path.join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) visit(absolute);
|
||||||
|
else if (entry.isFile() && entry.name.endsWith('.test.mjs')) {
|
||||||
|
found.push(path.relative(root, absolute).split(path.sep).join('/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(root);
|
||||||
|
return found.sort();
|
||||||
|
}
|
||||||
|
|
||||||
test('unit runner serializes only GC-bracketed allocation microbenchmarks', () => {
|
test('unit runner serializes only GC-bracketed allocation microbenchmarks', () => {
|
||||||
const ordinary = [
|
const ordinary = [
|
||||||
'src/data/manager.test.mjs',
|
'src/data/manager.test.mjs',
|
||||||
@@ -37,6 +63,30 @@ test('unit runner serializes only GC-bracketed allocation microbenchmarks', () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the runner discovers every test file in the repository', () => {
|
||||||
|
// CI's unit-test job runs `npm test`, which runs exactly what this function
|
||||||
|
// returns. A `*.test.mjs` outside the searched roots therefore never runs
|
||||||
|
// there, while still reading — in review and to its author — like covered
|
||||||
|
// code. Compare the two sets rather than their sizes, so the failure names the
|
||||||
|
// files instead of only disagreeing about a count. The final equality also
|
||||||
|
// catches the reverse: files the runner finds that this walk skips.
|
||||||
|
const discovered = discoverUnitTestFiles(repositoryRoot);
|
||||||
|
const onDisk = testFilesOnDisk(repositoryRoot);
|
||||||
|
const undiscovered = onDisk.filter((file) => !discovered.includes(file));
|
||||||
|
assert.deepEqual(
|
||||||
|
undiscovered,
|
||||||
|
[],
|
||||||
|
`test files the runner never executes: ${undiscovered.join(', ')}`,
|
||||||
|
);
|
||||||
|
assert.deepEqual(discovered, onDisk);
|
||||||
|
for (const file of discovered) {
|
||||||
|
assert.ok(
|
||||||
|
UNIT_TEST_ROOTS.some((root) => file.startsWith(`${root}/`)),
|
||||||
|
`${file} should sit under one of ${UNIT_TEST_ROOTS.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('allocation runtime calibration is explicit and pinned to Node 24', () => {
|
test('allocation runtime calibration is explicit and pinned to Node 24', () => {
|
||||||
assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0');
|
assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0');
|
||||||
assert.throws(
|
assert.throws(
|
||||||
|
|||||||
Reference in new issue
Block a user