mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
chore(ui): reconcile compact Radio ownership fix with current main
Preserve upstream changes, restore the reviewed Radio ownership regression,
and retain reporter raysung-2026 credit and #804 provenance.
Reconciles upstream main 81cf437 and PR #816 head 702ecc3; no force push.
This commit is contained in:
commit
c90e212049
42 files changed
+480
-122
No files matched your search
@@ -51,9 +51,11 @@ test('region ring: Natural Earth names resolve without a geocoder', async () =>
|
||||
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
|
||||
let adminSignal;
|
||||
let release;
|
||||
let lookupStarted;
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getAdministrativeAreas(_point, { signal }) {
|
||||
lookupStarted = Date.now();
|
||||
adminSignal = signal;
|
||||
return new Promise((resolve) => {
|
||||
release = resolve;
|
||||
@@ -61,7 +63,6 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
|
||||
},
|
||||
}),
|
||||
});
|
||||
const started = Date.now();
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Atlantis',
|
||||
undefined,
|
||||
@@ -73,8 +74,10 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
|
||||
ring: null,
|
||||
error: 'region-timeout',
|
||||
});
|
||||
// Timed from the boundary lookup, so the bundled pack scans before it do not
|
||||
// count. Under the 3 s default budget, so an ignored budgetMs still fails.
|
||||
assert.ok(
|
||||
Date.now() - started < 1000,
|
||||
Date.now() - lookupStarted < 2_000,
|
||||
'returns at the budget, not the lookup',
|
||||
);
|
||||
// The lookup is left running so it can fill the boundary cache.
|
||||
|
||||
@@ -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 () => {
|
||||
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(
|
||||
resolveCodexExecutable({
|
||||
environment: {},
|
||||
home: '/home/fixture',
|
||||
access(candidate) {
|
||||
checked.push(candidate);
|
||||
if (candidate.endsWith('/.local/bin/codex')) return;
|
||||
if (candidate === installed) return;
|
||||
throw new Error('missing');
|
||||
},
|
||||
}),
|
||||
path.join('/home/fixture', '.local', 'bin', 'codex'),
|
||||
installed,
|
||||
);
|
||||
assert.equal(checked.length, 1);
|
||||
|
||||
|
||||
@@ -2398,10 +2398,13 @@ test('a newer absolute intent aborts a hung guard-compensation instead of starvi
|
||||
removeGuard();
|
||||
// The newest intent must be able to abort the hung compensation and run.
|
||||
const pendingFinalOn = mgr.setEnabled('flights', true, { origin: 'user' });
|
||||
let giveUpTimer;
|
||||
const finalOn = await Promise.race([
|
||||
pendingFinalOn,
|
||||
new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)),
|
||||
]);
|
||||
new Promise((resolve) => {
|
||||
giveUpTimer = setTimeout(() => resolve('starved'), 2000);
|
||||
}),
|
||||
]).finally(() => clearTimeout(giveUpTimer));
|
||||
assert.notEqual(finalOn, 'starved', 'newest intent must not starve behind the compensation');
|
||||
assert.equal(compensationAborted, true, 'the hung compensation was aborted');
|
||||
assert.equal(await pendingBlockedOn, false, 'the guard-blocked request stays unfulfilled');
|
||||
@@ -2451,10 +2454,13 @@ test('re-entrant setEnabled from a blocked-adoption listener supersedes the comp
|
||||
releaseFirstDisable();
|
||||
|
||||
const blockedOn = await pendingBlockedOn;
|
||||
let giveUpTimer;
|
||||
const reentrant = await Promise.race([
|
||||
(async () => reentrantResult === null ? 'never-fired' : await reentrantResult)(),
|
||||
new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)),
|
||||
]);
|
||||
new Promise((resolve) => {
|
||||
giveUpTimer = setTimeout(() => resolve('starved'), 2000);
|
||||
}),
|
||||
]).finally(() => clearTimeout(giveUpTimer));
|
||||
await pendingOff;
|
||||
await mgr.waitForLayerSettled('flights');
|
||||
unsubscribe();
|
||||
|
||||
@@ -87,14 +87,22 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
|
||||
return articles;
|
||||
}
|
||||
|
||||
/** A reported number, or null when the observation is missing or not numeric. */
|
||||
function observedNumber(value) {
|
||||
if (
|
||||
value == null ||
|
||||
typeof value === 'boolean' ||
|
||||
(typeof value === 'string' && !value.trim())
|
||||
)
|
||||
return null;
|
||||
return Number.isFinite(Number(value)) ? Number(value) : null;
|
||||
}
|
||||
|
||||
/** Normalize Open-Meteo current conditions into a small source-stamped record. */
|
||||
export function normalizeRegionalWeather(payload) {
|
||||
const current = payload?.current;
|
||||
if (!current || !Number.isFinite(Number(current.temperature_2m))) return null;
|
||||
const numberOrNull = (value) => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
return Number.isFinite(Number(value)) ? Number(value) : null;
|
||||
};
|
||||
if (!current || observedNumber(current.temperature_2m) == null) return null;
|
||||
const numberOrNull = observedNumber;
|
||||
// Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC,
|
||||
// but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly.
|
||||
const observedRaw =
|
||||
@@ -119,8 +127,8 @@ export function normalizeRegionalWeather(payload) {
|
||||
|
||||
/** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */
|
||||
export function weatherCodeLabel(code) {
|
||||
const value = Number(code);
|
||||
if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN';
|
||||
const value = observedNumber(code);
|
||||
if (value == null) return 'CONDITIONS UNKNOWN';
|
||||
if (value === 0) return 'CLEAR';
|
||||
if ([1, 2].includes(value)) return 'PARTLY CLOUDY';
|
||||
if (value === 3) return 'OVERCAST';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { normalizeRegionalWeather, weatherCodeLabel } from './regionalModel.js';
|
||||
test('missing weather observations never assert clear conditions', () => {
|
||||
for (const value of [null, undefined, '', ' ', false, true]) {
|
||||
assert.equal(weatherCodeLabel(value), 'CONDITIONS UNKNOWN');
|
||||
assert.equal(
|
||||
normalizeRegionalWeather({ current: { temperature_2m: value } }),
|
||||
null,
|
||||
);
|
||||
}
|
||||
assert.equal(weatherCodeLabel(0), 'CLEAR');
|
||||
assert.equal(weatherCodeLabel('0'), 'CLEAR');
|
||||
const record = normalizeRegionalWeather({
|
||||
current: { temperature_2m: 0, weather_code: null, wind_speed_10m: false },
|
||||
});
|
||||
assert.equal(record.temperatureC, 0);
|
||||
assert.equal(record.weatherCode, null);
|
||||
assert.equal(record.windKph, null);
|
||||
});
|
||||
@@ -28,6 +28,11 @@ export function resolveCameraMove(scene, shot) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Wrap an interpolated camera longitude to WGS84 without changing authored endpoints. */
|
||||
export function normalizeCameraLongitude(longitude) {
|
||||
return ((((longitude + 180) % 360) + 360) % 360) - 180;
|
||||
}
|
||||
|
||||
/** Sample the same authored curve for playback and seeking, taking the short longitude/angle arc. */
|
||||
export function sampleCameraMove(move, progress) {
|
||||
const t = Math.max(0, Math.min(1, Number(progress) || 0));
|
||||
@@ -45,7 +50,7 @@ export function sampleCameraMove(move, progress) {
|
||||
const { from, to } = move;
|
||||
return {
|
||||
lat: lerp(from.lat, to.lat),
|
||||
lon: ((((angle(from.lon, to.lon) + 180) % 360) + 360) % 360) - 180,
|
||||
lon: normalizeCameraLongitude(angle(from.lon, to.lon)),
|
||||
alt: lerp(from.alt, to.alt),
|
||||
heading: angle(from.heading, to.heading),
|
||||
pitch: lerp(from.pitch, to.pitch),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { cameraAtProgress } from './timeline.js';
|
||||
const camera = (lon) => ({
|
||||
lat: 10,
|
||||
lon,
|
||||
alt: 1000,
|
||||
heading: 0,
|
||||
pitch: -40,
|
||||
roll: 0,
|
||||
});
|
||||
test('director seek follows the short dateline arc in both directions', () => {
|
||||
for (const [a, b] of [
|
||||
[179, -179],
|
||||
[-179, 179],
|
||||
]) {
|
||||
assert.equal(
|
||||
Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon),
|
||||
180,
|
||||
);
|
||||
assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a);
|
||||
assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b);
|
||||
assert.ok(Math.abs(cameraAtProgress(camera(a), camera(b), 0.25).lon) > 179);
|
||||
}
|
||||
assert.equal(cameraAtProgress(camera(20), camera(24), 0.5).lon, 22);
|
||||
});
|
||||
|
||||
test('director seek keeps exact ±180 endpoints and picks the short arc near the antipode', () => {
|
||||
for (const [a, b] of [
|
||||
[180, -180],
|
||||
[-180, 180],
|
||||
]) {
|
||||
assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a);
|
||||
assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b);
|
||||
assert.equal(
|
||||
Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon),
|
||||
180,
|
||||
);
|
||||
}
|
||||
// 10°E to 169.9°W is 179.9° westward through Greenwich, not 180.1° east.
|
||||
const mid = cameraAtProgress(camera(10), camera(-169.9), 0.5).lon;
|
||||
assert.ok(mid > -81 && mid < -79, `short arc midpoint, got ${mid}`);
|
||||
});
|
||||
|
||||
test('director seek turns heading and roll the short way for any authored angles', () => {
|
||||
const pose = (heading, roll) => ({ ...camera(0), heading, roll });
|
||||
const mid = cameraAtProgress(pose(350, 350), pose(-350, -350), 0.5);
|
||||
// 350° and -350° are 20° apart through 0°/360°, not 340° through 180°.
|
||||
for (const value of [mid.heading, mid.roll]) {
|
||||
const wrapped = ((value % 360) + 360) % 360;
|
||||
assert.ok(wrapped < 1e-9 || 360 - wrapped < 1e-9, `got ${value}`);
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
resolveCameraPose,
|
||||
resolveCameraMove,
|
||||
sampleCameraMove,
|
||||
normalizeCameraLongitude,
|
||||
} from './camera.js';
|
||||
|
||||
const DEFAULT_SHOT_DURATION_SEC = 4;
|
||||
@@ -40,12 +41,19 @@ export function cameraAtProgress(fromCamera, toCamera, progress) {
|
||||
const lerp = (from, to) => Number(from) + (Number(to) - Number(from)) * eased;
|
||||
const lerpAngle = (from, to) => {
|
||||
const start = Number(from) || 0;
|
||||
const delta = ((Number(to) - start + 540) % 360) - 180;
|
||||
// Double modulo keeps the delta in [-180, 180) for any authored angles,
|
||||
// as sampleCameraMove does; a single % goes negative below -540.
|
||||
const delta = ((((Number(to) - start + 540) % 360) + 360) % 360) - 180;
|
||||
return start + delta * eased;
|
||||
};
|
||||
return {
|
||||
lat: lerp(source.lat, target.lat),
|
||||
lon: lerp(source.lon, target.lon),
|
||||
lon:
|
||||
t === 0
|
||||
? Number(source.lon)
|
||||
: t === 1
|
||||
? Number(target.lon)
|
||||
: normalizeCameraLongitude(lerpAngle(source.lon, target.lon)),
|
||||
alt: lerp(source.alt, target.alt),
|
||||
heading: lerpAngle(source.heading, target.heading),
|
||||
pitch: lerp(source.pitch, target.pitch),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs';
|
||||
|
||||
test('gates keep macOS Chrome where it is installed', () => {
|
||||
assert.equal(
|
||||
chromeExecutablePath({ env: {}, exists: (p) => p === MACOS_CHROME }),
|
||||
MACOS_CHROME,
|
||||
);
|
||||
});
|
||||
|
||||
test('gates fall back to Puppeteer’s browser without macOS Chrome', () => {
|
||||
assert.equal(
|
||||
chromeExecutablePath({ env: {}, exists: () => false }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('an explicit executable path wins', () => {
|
||||
assert.equal(
|
||||
chromeExecutablePath({
|
||||
env: { PUPPETEER_EXECUTABLE_PATH: 'C:\\Chrome\\chrome.exe' },
|
||||
exists: () => true,
|
||||
}),
|
||||
'C:\\Chrome\\chrome.exe',
|
||||
);
|
||||
});
|
||||
@@ -357,10 +357,11 @@ test('WebUSB picker remains available when no authorized receiver matches', asyn
|
||||
|
||||
/** Resolve with the promise's value, or 'timed out' after `ms`. */
|
||||
function within(promise, ms = 1_000) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((resolve) => setTimeout(() => resolve('timed out'), ms)),
|
||||
]);
|
||||
let timer;
|
||||
const timeout = new Promise((resolve) => {
|
||||
timer = setTimeout(() => resolve('timed out'), ms);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+4
-2
@@ -29,11 +29,13 @@ export const snowShader = {
|
||||
float depth = 0.5 + layer * 0.5;
|
||||
float speed = 0.4 + layer * 0.3;
|
||||
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);
|
||||
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 f = fract(snowUV);
|
||||
|
||||
+14
-4
@@ -106,10 +106,20 @@ export function validateValue(schema, value, path = 'arguments') {
|
||||
fail('must be a string');
|
||||
break;
|
||||
}
|
||||
if (schema.minLength != null && value.length < schema.minLength)
|
||||
fail(`must be at least ${schema.minLength} characters`);
|
||||
if (schema.maxLength != null && value.length > schema.maxLength)
|
||||
fail(`must be at most ${schema.maxLength} characters`);
|
||||
if (schema.minLength != null || schema.maxLength != null) {
|
||||
// Code points, as JSON Schema counts. Counting stops once both bounds
|
||||
// are decided, so a huge argument costs no more than its limit.
|
||||
const limit = Math.max(
|
||||
schema.minLength ?? 0,
|
||||
schema.maxLength != null ? schema.maxLength + 1 : 0,
|
||||
);
|
||||
let length = 0;
|
||||
for (const _ of value) if (++length >= limit) break;
|
||||
if (schema.minLength != null && length < schema.minLength)
|
||||
fail(`must be at least ${schema.minLength} characters`);
|
||||
if (schema.maxLength != null && length > schema.maxLength)
|
||||
fail(`must be at most ${schema.maxLength} characters`);
|
||||
}
|
||||
if (
|
||||
schema.pattern !== undefined &&
|
||||
!new RegExp(schema.pattern, 'u').test(value)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { validateValue } from './schema.js';
|
||||
test('tool string schemas count Unicode characters rather than UTF-16 units', () => {
|
||||
const value = '𠮷野家😀';
|
||||
assert.deepEqual(validateValue({ type: 'string', maxLength: 4 }, value), []);
|
||||
assert.equal(
|
||||
validateValue({ type: 'string', minLength: 5 }, value).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
validateValue({ type: 'string', maxLength: 3 }, value).length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
validateValue({ type: 'string', minLength: 4, maxLength: 4 }, 'abcd'),
|
||||
[],
|
||||
);
|
||||
assert.equal(validateValue({ type: 'string', minLength: 2 }, '𠮷').length, 1);
|
||||
});
|
||||
|
||||
test('tool string bounds hold at their edges and on huge arguments', () => {
|
||||
const string = (bounds) => ({ type: 'string', ...bounds });
|
||||
assert.deepEqual(validateValue(string({ maxLength: 3 }), '😀😀😀'), []);
|
||||
assert.equal(validateValue(string({ maxLength: 3 }), '😀😀😀😀').length, 1);
|
||||
assert.deepEqual(validateValue(string({ minLength: 3 }), '😀😀😀'), []);
|
||||
assert.deepEqual(validateValue(string({}), 'x'.repeat(1e6)), []);
|
||||
// Counting all of it takes roughly 0.7 s; stopping at the limit takes none.
|
||||
const huge = 'x'.repeat(1e8);
|
||||
const started = performance.now();
|
||||
assert.equal(validateValue(string({ maxLength: 60 }), huge).length, 1);
|
||||
assert.ok(performance.now() - started < 200, 'stops counting past the limit');
|
||||
});
|
||||
@@ -1,14 +1,40 @@
|
||||
import test from 'node:test';
|
||||
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 {
|
||||
ALLOCATION_TEST_FILES,
|
||||
UNIT_TEST_ROOTS,
|
||||
allocationTestArgs,
|
||||
assertNode24AllocationRuntime,
|
||||
buildUnitTestPlan,
|
||||
discoverUnitTestFiles,
|
||||
isCalibratedAllocationRuntime,
|
||||
} 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', () => {
|
||||
const ordinary = [
|
||||
'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', () => {
|
||||
assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0');
|
||||
assert.throws(
|
||||
|
||||
@@ -13,7 +13,13 @@ function range01(value, min, max) {
|
||||
* Missing weather fails clear instead of inventing conditions.
|
||||
*/
|
||||
export function deriveWeatherEffectProfile(weather) {
|
||||
if (!weather || !Number.isFinite(Number(weather.weatherCode))) {
|
||||
const reported = weather?.weatherCode;
|
||||
// Number(null) and Number('') are 0 (clear), so a missing code is checked first.
|
||||
if (
|
||||
reported == null ||
|
||||
reported === '' ||
|
||||
!Number.isFinite(Number(reported))
|
||||
) {
|
||||
return {
|
||||
available: false,
|
||||
cloud: 0,
|
||||
|
||||
@@ -10,6 +10,15 @@ test('missing weather fails clear instead of inventing effects', () => {
|
||||
assert.equal(profile.storm, 0);
|
||||
});
|
||||
|
||||
test('a missing weather code is unavailable, not clear', () => {
|
||||
for (const weatherCode of [null, undefined, '']) {
|
||||
const profile = deriveWeatherEffectProfile({ weatherCode, cloudCoverPct: 90 });
|
||||
assert.equal(profile.available, false, `weatherCode ${weatherCode}`);
|
||||
assert.equal(profile.cloud, 0);
|
||||
}
|
||||
assert.equal(deriveWeatherEffectProfile({ weatherCode: 0 }).available, true);
|
||||
});
|
||||
|
||||
test('clear weather follows observed cloud cover without precipitation', () => {
|
||||
const profile = deriveWeatherEffectProfile({
|
||||
weatherCode: 1,
|
||||
|
||||
Reference in new issue
Block a user