Files
meshcore-analyzer/test-hash-color.js
T
Kpa-clawbot b9758111b0 feat(hash-color): bright vivid fill + dark outline + live feed/polyline surfaces (#951)
## Hash-Color: Bright Vivid Fill + Dark Outline + Extended Surfaces

Follow-up to #948 (merged). Revises the hash-color algorithm for better
perceptual discrimination and extends hash coloring to additional Live
page surfaces.

### Algorithm Changes (`public/hash-color.js`)
- **Hue**: bytes 0-1 (16-bit → 0-360°) — unchanged
- **Saturation**: byte 2 (55-95%) — NEW, was fixed 70%
- **Lightness**: byte 3 (light 50-65%, dark 55-72%) — NEW, was fixed
L=30/38/65
- **Outline** (`hashToOutline`): same-hue dark color (L=25% light, L=15%
dark) — NEW
- Sentinel threshold raised to 8 hex chars (need 4 bytes of entropy)
- Drops WCAG fill-darkening approach — outline carries contrast instead

### Live Page Updates (`public/live.js`)
- **Dot marker**: uses `hashToOutline()` for stroke (was TYPE_COLOR)
- **Polyline trace**: uses hash fill color (unified dot + trace by hash)
- **Feed items**: 4px `border-left` stripe matching packets table

### Test Updates
- `test-hash-color.js`: 32 tests (S variability, L variability, outline
< fill, same hue, pairwise distance)
- `test-e2e-playwright.js`: 2 new assertions (feed stripe, polyline hsl
stroke)

### Verification
- 20 real advert hashes from fixture DB: all produce unique hues (20/20)
- Pairwise HSL distance: avg=0.51, min=0.04
- Go server built and run against fixture DB — HTML serves updated
module
- VM sandbox render-check confirms distinct vivid fills with darker
outlines

Closes #946 §2.10/§2.11 scope extension.

---------

Co-authored-by: you <you@example.com>
Co-authored-by: Kpa-clawbot <bot@example.invalid>
2026-05-01 08:53:04 +00:00

151 lines
6.6 KiB
JavaScript

/* test-hash-color.js — Unit tests for hash-color.js (vm.createContext sandbox)
* Tests: purity, theme split, saturation variability, lightness variability,
* outline darker than fill, sentinel, perceptual distance
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const path = require('path');
const src = fs.readFileSync(path.join(__dirname, 'public', 'hash-color.js'), 'utf8');
function createSandbox() {
const sandbox = { window: {}, module: {} };
vm.createContext(sandbox);
vm.runInContext(src, sandbox);
return sandbox.window.HashColor || sandbox.module.exports;
}
const HashColor = createSandbox();
let passed = 0;
let failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
function parseHsl(str) {
const m = str.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/);
if (!m) return null;
return { h: parseInt(m[1]), s: parseInt(m[2]), l: parseInt(m[3]) };
}
// --- Purity: same input → same output ---
console.log('Purity:');
const r1 = HashColor.hashToHsl('a1b2c3d4', 'light');
const r2 = HashColor.hashToHsl('a1b2c3d4', 'light');
assert(r1 === r2, 'Same hash+theme → identical output');
const r3 = HashColor.hashToHsl('a1b2c3d4', 'light');
assert(r1 === r3, 'Third call still identical (no internal state)');
// --- Theme split: light vs dark produce different L ---
console.log('Theme split:');
const light = HashColor.hashToHsl('ff00aa80', 'light');
const dark = HashColor.hashToHsl('ff00aa80', 'dark');
assert(light !== dark, 'Light and dark produce different colors for same hash');
const lightP = parseHsl(light);
const darkP = parseHsl(dark);
assert(lightP.l >= 50 && lightP.l <= 65, 'Light theme L in [50,65] (got ' + lightP.l + ')');
assert(darkP.l >= 55 && darkP.l <= 72, 'Dark theme L in [55,72] (got ' + darkP.l + ')');
// --- Saturation varies with byte 2 ---
console.log('Saturation variability (byte 2):');
const lowSat = HashColor.hashToHsl('000000ff', 'light'); // byte2=0x00
const highSat = HashColor.hashToHsl('0000ffff', 'light'); // byte2=0xff
const lowSatP = parseHsl(lowSat);
const highSatP = parseHsl(highSat);
assert(lowSatP.s === 55, 'byte2=0x00 → S=55% (got ' + lowSatP.s + ')');
assert(highSatP.s === 95, 'byte2=0xff → S=95% (got ' + highSatP.s + ')');
// Mid value
const midSat = HashColor.hashToHsl('00008000', 'light'); // byte2=0x80
const midSatP = parseHsl(midSat);
assert(midSatP.s > 55 && midSatP.s < 95, 'byte2=0x80 → S between 55 and 95 (got ' + midSatP.s + ')');
// --- Lightness varies with byte 3 ---
console.log('Lightness variability (byte 3):');
const lowL = HashColor.hashToHsl('00000000', 'light'); // byte3=0x00
const highL = HashColor.hashToHsl('000000ff', 'light'); // byte3=0xff
const lowLP = parseHsl(lowL);
const highLP = parseHsl(highL);
assert(lowLP.l === 50, 'byte3=0x00 light → L=50 (got ' + lowLP.l + ')');
assert(highLP.l === 65, 'byte3=0xff light → L=65 (got ' + highLP.l + ')');
const lowLD = HashColor.hashToHsl('00000000', 'dark');
const highLD = HashColor.hashToHsl('000000ff', 'dark');
assert(parseHsl(lowLD).l === 55, 'byte3=0x00 dark → L=55 (got ' + parseHsl(lowLD).l + ')');
assert(parseHsl(highLD).l === 72, 'byte3=0xff dark → L=72 (got ' + parseHsl(highLD).l + ')');
// --- Outline is darker than fill ---
console.log('Outline darker than fill:');
['a1b2c3d4', 'ff00aa80', '12345678', 'deadbeef'].forEach(h => {
['light', 'dark'].forEach(theme => {
const fill = parseHsl(HashColor.hashToHsl(h, theme));
const outline = parseHsl(HashColor.hashToOutline(h, theme));
assert(outline.l < fill.l, 'Outline L(' + outline.l + ') < Fill L(' + fill.l + ') for ' + h + '/' + theme);
});
});
// --- Outline same hue as fill ---
console.log('Outline same hue as fill:');
['a1b2c3d4', 'deadbeef'].forEach(h => {
const fill = parseHsl(HashColor.hashToHsl(h, 'light'));
const outline = parseHsl(HashColor.hashToOutline(h, 'light'));
assert(fill.h === outline.h, 'Hue matches: fill=' + fill.h + ' outline=' + outline.h + ' for ' + h);
});
// --- Sentinel: null/empty/short hash ---
console.log('Sentinel:');
assert(HashColor.hashToHsl(null, 'light') === 'hsl(0, 0%, 50%)', 'null → sentinel');
assert(HashColor.hashToHsl('', 'light') === 'hsl(0, 0%, 50%)', 'empty string → sentinel');
assert(HashColor.hashToHsl('ab', 'dark') === 'hsl(0, 0%, 50%)', 'too short (2 chars) → sentinel');
assert(HashColor.hashToHsl('abcdef', 'dark') === 'hsl(0, 0%, 50%)', '6 chars (need 8) → sentinel');
assert(HashColor.hashToHsl(undefined, 'dark') === 'hsl(0, 0%, 50%)', 'undefined → sentinel');
assert(HashColor.hashToOutline(null, 'light') === 'hsl(0, 0%, 30%)', 'null outline → sentinel');
// --- Variability: different hashes → different colors (anti-tautology) ---
console.log('Variability (anti-tautology):');
const colors = new Set();
['00008080', '80008080', 'ff008080', '00ff8080', 'ffff8080'].forEach(h => {
colors.add(HashColor.hashToHsl(h, 'light'));
});
assert(colors.size >= 4, 'At least 4 distinct colors from 5 different hashes (got ' + colors.size + ')');
// Adjacent hashes differ
const c1 = HashColor.hashToHsl('01008080', 'light');
const c2 = HashColor.hashToHsl('02008080', 'light');
assert(c1 !== c2, 'Adjacent hashes produce different colors');
// --- Perceptual distance: sample 50 hashes, compute pairwise HSL distance ---
console.log('Perceptual distance (50 sample hashes):');
function hslDistance(a, b) {
// Simple cylindrical distance: weight hue wrap, sat, lightness
var dh = Math.min(Math.abs(a.h - b.h), 360 - Math.abs(a.h - b.h)) / 180; // 0-1
var ds = Math.abs(a.s - b.s) / 100; // 0-1
var dl = Math.abs(a.l - b.l) / 100; // 0-1
return Math.sqrt(dh*dh + ds*ds + dl*dl);
}
const deterministicHashes = [];
for (var i = 0; i < 50; i++) {
var hex = ('0000000' + (i * 5347 + 12345).toString(16)).slice(-8);
deterministicHashes.push(hex);
}
const parsedColors = deterministicHashes.map(h => parseHsl(HashColor.hashToHsl(h, 'light')));
var distances = [];
for (var i = 0; i < parsedColors.length; i++) {
for (var j = i + 1; j < parsedColors.length; j++) {
distances.push(hslDistance(parsedColors[i], parsedColors[j]));
}
}
var avgDist = distances.reduce((a, b) => a + b, 0) / distances.length;
var minDist = Math.min(...distances);
console.log(' Avg pairwise HSL distance: ' + avgDist.toFixed(4));
console.log(' Min pairwise HSL distance: ' + minDist.toFixed(4));
assert(avgDist > 0.15, 'Average pairwise distance > 0.15 (got ' + avgDist.toFixed(4) + ')');
assert(minDist > 0.01, 'Min pairwise distance > 0.01 (got ' + minDist.toFixed(4) + ')');
// --- Summary ---
console.log('\n' + passed + ' passed, ' + failed + ' failed');
if (failed > 0) process.exit(1);