SUNDIAL solar dial v0.3: round screen, NOAA solar math, day/night arc
- 466px round layout, designWidth 466 (S4 41mm 466x466) - solar position + dayProgress in src/common/solar.js (pure, TDD'd) - sunrise/sunset sweep, night moon, countdown, 30s refresh - tests: solar math, page logic, circle geometry/contrast
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const assert = require('node:assert/strict');
|
||||
const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8');
|
||||
const script = ux.match(/<script>([\s\S]*?)<\/script>/)[1];
|
||||
function page() {
|
||||
const spec = vm.runInNewContext(script.replace('export default', 'globalThis.spec =') + '\nspec');
|
||||
return Object.assign({}, spec.private, spec);
|
||||
}
|
||||
const p = page();
|
||||
assert.equal(typeof p.press, 'function', 'Keypress handler must exist');
|
||||
['2','+','3','='].forEach(k => p.press(k));
|
||||
assert.equal(p.display, '5');
|
||||
console.log('PASS addition through page keypress handler');
|
||||
const cases = [
|
||||
['subtract',['9','-','4','='],'5'],
|
||||
['multiply',['6','x','7','='],'42'],
|
||||
['divide',['8','/','2','='],'4'],
|
||||
['decimals',['0','.','1','+','0','.','2','='],'0.3'],
|
||||
['duplicate dot',['1','.','.','2'],'1.2'],
|
||||
['sign',['5','+/-','x','2','='],'-10'],
|
||||
['percent',['5','0','%'],'0.5'],
|
||||
['percent add',['2','0','0','+','1','0','%','='],'220'],
|
||||
['percent multiply',['2','0','0','x','1','0','%','='],'20'],
|
||||
['clear',['9','+','AC','2','='],'2'],
|
||||
['zero division',['8','/','0','='],'Error'],
|
||||
['recover',['8','/','0','=','3'],'3'],
|
||||
['chain',['2','+','3','x','4','='],'20'],
|
||||
['replace operator',['2','+','x','3','='],'6'],
|
||||
['repeat equals',['2','+','3','=','=', '='],'11'],
|
||||
['fresh entry',['2','+','3','=','7'],'7'],
|
||||
['input cap',Array(20).fill('9'),'999999999'],
|
||||
['leading zero',['0','0','2'],'2']
|
||||
];
|
||||
for(const [name,keys,expected] of cases) {
|
||||
const c=page(); keys.forEach(k=>c.press(k)); assert.equal(c.display,expected,name);
|
||||
}
|
||||
assert.match(ux, /\{\{ display \}\}/);
|
||||
const keys=[...ux.matchAll(/<text[^>]*onclick="press\('([^']+)'\)"/g)].map(m=>m[1]);
|
||||
assert.equal(keys.length,19,'every button must have handler');
|
||||
assert.equal(new Set(keys).size,19);
|
||||
console.log('PASS',cases.length,'edge cases and all 19 button bindings');
|
||||
@@ -0,0 +1,32 @@
|
||||
// Compatibility contract, NOT a reproduction of the watch's native crash.
|
||||
// Marker must use documented numeric positioning, not chained transforms.
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const assert = require('node:assert/strict');
|
||||
const solar = require('../src/common/solar.js');
|
||||
const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8');
|
||||
const body = ux.match(/<script>([\s\S]*?)<\/script>/)[1]
|
||||
.replace(/import\s+solar\s+from\s+'[^']*';?/, '')
|
||||
.replace('export default', '');
|
||||
const spec = vm.runInNewContext('(' + body + ')', {solar, Date, Math, String, Number});
|
||||
const page = Object.assign({}, spec, JSON.parse(JSON.stringify(spec.private)));
|
||||
page.onInit();
|
||||
page.update();
|
||||
assert.ok(page.markerStyle && Number.isFinite(parseFloat(page.markerStyle.left)),
|
||||
'marker must expose a finite left coordinate via a bound style object');
|
||||
assert.match(ux, /style="{{\s*markerStyle\s*}}"/);
|
||||
assert.doesNotMatch(ux, /transform\s*:/);
|
||||
assert.doesNotMatch(ux, /width:\s*0px/);
|
||||
const size = Number(ux.match(/\.sunDot\s*\{[^}]*width:\s*(\d+)px/)[1]);
|
||||
// Check actual bound marker box, not an unused constant.
|
||||
for (let angle = -270; angle <= 90; angle++) {
|
||||
page.sunAngle = angle;
|
||||
page.positionMarker();
|
||||
const x = parseFloat(page.markerStyle.left), y = parseFloat(page.markerStyle.top);
|
||||
assert.ok(Number.isInteger(x) && Number.isInteger(y));
|
||||
for (const dx of [0, size]) for (const dy of [0, size]) {
|
||||
assert.ok(Math.hypot(x + dx - 233, y + dy - 233) <= 200,
|
||||
`marker box leaves the 400px dial at angle ${angle}`);
|
||||
}
|
||||
}
|
||||
console.log('MARKER PASS: bound coordinates, no transforms, actual box fits all angles');
|
||||
@@ -0,0 +1,46 @@
|
||||
// Test the page's update() logic: clock, phase, angle mapping, countdown wiring.
|
||||
const fs = require('node:fs');
|
||||
const assert = require('node:assert/strict');
|
||||
const solar = require('../src/common/solar.js');
|
||||
|
||||
const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8');
|
||||
const script = ux.match(/<script>([\s\S]*?)<\/script>/)[1];
|
||||
// strip imports, provide solar as global
|
||||
const body = script.replace(/import\s+solar\s+from\s+'[^']*';?/, '');
|
||||
const sandbox = { solar, setInterval: () => 0, clearInterval: () => {}, Date, Math, String, Number, console };
|
||||
const vm = require('node:vm');
|
||||
const ctx = vm.createContext(sandbox);
|
||||
sandbox.spec = vm.runInContext('(' + body.replace('export default', '') + ')', ctx);
|
||||
const spec = sandbox.spec;
|
||||
|
||||
function mk() {
|
||||
const o = Object.assign({}, JSON.parse(JSON.stringify(spec.private || {})));
|
||||
for (const k in spec) if (k !== 'private' && k !== 'computed') o[k] = spec[k].bind(o);
|
||||
o.$element = () => null;
|
||||
spec.onInit.call(o);
|
||||
return o;
|
||||
}
|
||||
|
||||
// midday Paris: DAY, angle between -90 and +90, countdown mentions sunset
|
||||
let c = mk();
|
||||
let fixedNow = new Date('2026-09-09T12:00:00Z');
|
||||
const realDate = Date;
|
||||
class FakeDate extends realDate {
|
||||
constructor(...args) { super(...(args.length ? args : [fixedNow])); }
|
||||
static now() { return fixedNow.getTime(); }
|
||||
}
|
||||
sandbox.Date = FakeDate;
|
||||
c.update();
|
||||
assert.match(c.clock, /^\d{2}:\d{2}$/);
|
||||
assert.equal(c.isDay, true, 'Paris noon is day');
|
||||
assert.ok(c.sunAngle > -90 && c.sunAngle < 90, `day angle within upper arc, got ${c.sunAngle}`);
|
||||
assert.match(c.countdown, /sunset in/);
|
||||
|
||||
// night: angle in lower arc, countdown mentions sunrise
|
||||
fixedNow = new Date('2026-09-09T22:00:00Z');
|
||||
c.update();
|
||||
assert.equal(c.isDay, false, 'Paris 22:00Z is night');
|
||||
assert.ok(c.sunAngle < -90 || c.sunAngle > 90, `night angle in lower arc, got ${c.sunAngle}`);
|
||||
assert.match(c.countdown, /sunrise in/);
|
||||
|
||||
console.log('PAGE LOGIC PASS (day arc, night arc, countdowns)');
|
||||
@@ -0,0 +1,40 @@
|
||||
// TDD: solar position math — RFC-accurate NOAA algorithm, pure functions.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const src = fs.readFileSync('src/common/solar.js', 'utf8');
|
||||
const mod = {};
|
||||
new Function('module', 'exports', src)(mod, mod.exports || (mod.exports = {}));
|
||||
const { solarPosition, dayProgress, formatCountdown } = mod.exports;
|
||||
|
||||
// Reference: NOAA solar calculator, 2026-09-09, Paris (48.8566, 2.3522), 12:00 UTC ≈ solar noon-ish
|
||||
const p = solarPosition(new Date('2026-09-09T12:00:00Z'), 48.8566, 2.3522);
|
||||
assert.ok(Number.isFinite(p.altitude) && Number.isFinite(p.azimuth), 'finite angles');
|
||||
assert.ok(p.altitude > 35 && p.altitude < 60, `Paris noon September altitude ~45°, got ${p.altitude}`);
|
||||
|
||||
// Night: same place, 00:30 UTC → sun below horizon
|
||||
const n = solarPosition(new Date('2026-09-09T00:30:00Z'), 48.8566, 2.3522);
|
||||
assert.ok(n.altitude < -20, `midnight Paris altitude strongly negative, got ${n.altitude}`);
|
||||
|
||||
// Southern hemisphere summer: Sydney January noon → high sun
|
||||
const syd = solarPosition(new Date('2026-01-15T03:00:00Z'), -33.8688, 151.2093);
|
||||
assert.ok(syd.altitude > 55, `Sydney Jan noon altitude high, got ${syd.altitude}`);
|
||||
|
||||
// dayProgress: 0 at sunrise, 1 at sunset, 0.5 ≈ solar noon
|
||||
const dp = dayProgress(new Date('2026-09-09T12:00:00Z'), 48.8566, 2.3522);
|
||||
assert.ok(dp > 0.4 && dp < 0.6, `midday progress ≈ 0.5, got ${dp}`);
|
||||
assert.equal(dayProgress(new Date('2026-09-09T00:30:00Z'), 48.8566, 2.3522), 0, 'before sunrise → 0');
|
||||
assert.equal(dayProgress(new Date('2026-09-09T22:00:00Z'), 48.8566, 2.3522), 1, 'after sunset → 1');
|
||||
|
||||
// Polar night / midnight sun don't crash
|
||||
solarPosition(new Date('2026-06-21T12:00:00Z'), 78.22, 15.63); // Svalbard
|
||||
solarPosition(new Date('2026-12-21T12:00:00Z'), 78.22, 15.63);
|
||||
const polar = dayProgress(new Date('2026-12-21T12:00:00Z'), 78.22, 15.63);
|
||||
assert.ok(polar === 0 || polar === 1, 'polar night returns boundary');
|
||||
|
||||
// countdown formatting
|
||||
assert.equal(formatCountdown(3725), '1h 2m');
|
||||
assert.equal(formatCountdown(600), '10m');
|
||||
assert.equal(formatCountdown(45), '<1m');
|
||||
assert.equal(formatCountdown(0), '0m');
|
||||
|
||||
console.log('ALL SOLAR MATH PASS');
|
||||
@@ -0,0 +1,34 @@
|
||||
|
||||
// Circle geometry: sun marker orbit, horizon line, center text block — all inside 466px circle.
|
||||
const fs = require('node:fs');
|
||||
const assert = require('node:assert/strict');
|
||||
const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8');
|
||||
const num = (re) => { const m = ux.match(re); assert.ok(m, 'missing: '+re); return Number(m[1]); };
|
||||
const SCREEN = 466, R = 233;
|
||||
const skyD = num(/\.sky\s*{[^}]*width:\s*(\d+)px/);
|
||||
assert.ok(skyD <= SCREEN - 40, 'sky dial should leave bezel margin: ' + skyD);
|
||||
// horizon line spans within circle at y=233 (widest point) → width <= 466
|
||||
const hzW = num(/\.horizon\s*{[^}]*width:\s*(\d+)px/);
|
||||
assert.ok(hzW <= SCREEN, 'horizon width fits');
|
||||
// Actual template-bound coordinates are exercised in marker.cjs.
|
||||
const orbit = num(/orbitR:\s*(\d+)/);
|
||||
assert.ok(orbit <= skyD / 2 - 30, `sun orbit ${orbit} must stay within sky dial ${skyD/2} minus marker radius`);
|
||||
// center text block vertical span inside circle
|
||||
const cTop = num(/\.center\s*{[^}]*top:\s*(\d+)px/);
|
||||
const bigH = num(/\.big\s*{[^}]*height:\s*(\d+)px/);
|
||||
const phaseH = num(/\.phase\s*{[^}]*height:\s*(\d+)px/);
|
||||
const countH = num(/\.count\s*{[^}]*height:\s*(\d+)px/);
|
||||
const cBot = cTop + bigH + phaseH + countH;
|
||||
// text content is centered and narrow (~240px wide worst case = clock at 66px font); check half-width 120px fits chord
|
||||
const TEXT_HALF = 120;
|
||||
for (const y of [cTop, cBot]) {
|
||||
const halfW = Math.sqrt(Math.max(0, R*R - Math.abs(y-R)**2));
|
||||
assert.ok(TEXT_HALF <= halfW, `center text at y=${y} exceeds chord: needs ${TEXT_HALF}, has ${halfW.toFixed(0)}`);
|
||||
}
|
||||
// contrast
|
||||
const lum = h => { const c=h.match(/\w\w/g).map(x=>parseInt(x,16)/255).map(v=>v<=0.03928?v/12.92:((v+0.055)/1.055)**2.4); return .2126*c[0]+.7152*c[1]+.0722*c[2]; };
|
||||
const ratio = (a,b)=>(Math.max(lum(a),lum(b))+.05)/(Math.min(lum(a),lum(b))+.05);
|
||||
assert.ok(ratio('#ffffff','#000000') >= 4.5, 'clock contrast');
|
||||
assert.ok(ratio('#f3a633','#000000') >= 4.5, 'phase contrast');
|
||||
assert.ok(ratio('#9aa0aa','#000000') >= 4.5, 'count contrast');
|
||||
console.log('GEOMETRY ESTIMATES PASS (not renderer validation): dial', skyD, 'orbit', orbit, 'center block y', cTop, '-', cBot);
|
||||
Reference in New Issue
Block a user