Files
behind_glass/scripts/test-region-axis-split.mjs
T
horoli e358e24436 Replace project with current server release and Windows setup
Import the 2026-10-09 source release, Fastify/SQLite backend, restored source
assets, and analytics-enabled server build. Replace files from the previous
release while retaining the existing repository history.

Make builds portable across Windows and Linux, configure local startup,
normalize Windows static asset paths, and fix test fixture cleanup and file
URL handling. Exclude local environment settings, databases, dependencies,
validation output, and TypeScript build cache from version control.

Validation: npm ci, static and server builds, 106 arrival tests, and actual
local HTTP checks passed. The full suite passed 467 of 470 tests; three tests
still require QA fixtures omitted from the supplied release, documented in
README-서버실행.md.
2026-10-09 16:43:35 +09:00

176 lines
12 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const shader = await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8');
const paletteShader = await readFile(new URL('../src/shaders/region-palette.frag', import.meta.url), 'utf8');
const historyShader = await readFile(new URL('../src/shaders/region-history.frag', import.meta.url), 'utf8');
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8') + await readFile(new URL('../src/rendering/OriginalGlassRenderer.ts',import.meta.url),'utf8');
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
assert.match(main, /uGridSize: \{ value: GOBANG_GLASS.grid \}/);
assert.match(main, /uStrength: \{ value: 1 \}/);
assert.match(main, /uRegionStrength: \{ value: 0 \}/);
assert.match(main, /uCurvature: \{ value: 1 \}/);
assert.match(main, /uRegionPaletteSize: \{ value: new THREE\.Vector2\(65, 13\) \}/);
assert.match(main, /new THREE\.WebGLRenderTarget\(65, 13/);
assert.match(main, /paletteWidth = gridX \* 5/);
assert.match(html, /id="grid-size" type="range" min="10" max="24" step="1" value="20"/);
assert.match(html, /<select id="curvature">/);
assert.doesNotMatch(html, /id="gloss-strength"|id="glossOut"/);
assert.match(main, /uGlossStrength: \{ value: 0\.3 \}/);
assert.match(html, /id="region-fixed-note"/);
assert.doesNotMatch(html, /id="region-strength"|id="strength"|id="strengthOut"/);
assert.doesNotMatch(main, /strengthInput|strengthOutput|regionStrengthInput|regionStrengthOutput|region-strength|strengthOut/);
for (const [, id] of main.matchAll(/document\.querySelector\('#([^']+)'\)/g)) {
assert.ok(html.includes(`id="${id}"`), `main DOM reference #${id} must exist in index.html`);
}
assert.match(main, /uniforms\.uGridSize\.value = style === 0 \? 13 : style === 2 \? GOBANG_GLASS.grid : 24; uniforms\.uRegionStrength\.value = style === 0 \? 1 : 0; uniforms\.uStrength\.value = 1/);
assert.match(main, /setMode\('final'\);\s*if \(!renderLost && !meshFailure && arrivalToken !== null && arrivalToken === photoArrivalToken\) arrivalTracker\?\.arrived\(arrivalToken\);\s*experience\.setState\('ready'\);\s*setStatus\(automatic \?/);
assert.match(html, /id="panel-photo"[^>]*role="tabpanel"[\s\S]*사진 구도/);
assert.match(html, /<details id="developer-tools"><summary>개발 도구/);
assert.match(html, /<details id="developer-tools">[\s\S]*id="video"[\s\S]*id="depth-image"[\s\S]*id="view-mode"[\s\S]*id="effect"[\s\S]*id="depth-scale"[\s\S]*id="light-x"[\s\S]*id="sensitivity"/);
assert.match(shader, /cell\.x\*5\.\+index/);
assert.match(shader, /for\(int y=-1;y<=1;y\+\+\)for\(int x=-1;x<=1;x\+\+\)/);
assert.match(shader, /candidateColor=mix\(radialColor,splitColor,splitHistory\.a\)/);
assert.match(shader, /splitColor=splitHistory\.a>\.00001\?splitHistory\.rgb\/splitHistory\.a:radialColor/);
assert.match(historyShader, /gl_FragColor=mix\(previous,current,clamp\(uHistoryBlend,0\.,1\.\)\)/);
assert.match(historyShader, /vec4 current=vec4\(paletteColor\(cell,label\)\*confidence,confidence\)/);
assert.match(historyShader, /cell\.x\*5\.\+4\./);
assert.doesNotMatch(shader, /protectDark|ownPalette|darkestPalette|protectedColor|assignedColor/);
assert.match(shader, /atlasEvidence=regionProtectionWeight\(finalSceneUv\)\*\(1\.-smoothstep\(\.10,\.20,regionLuminance\(atlasCenterColor\)\)\)/);
assert.match(shader, /color\.a=atlasEvidence/);
assert.equal((shader.match(/regionAtlasProbe\(/g) ?? []).length, 5, 'atlas sampling must use one probe helper plus exactly four fixed probe calls');
assert.match(shader, /probeStep=uPaneSize\/\(uRegionGridSize\*8\.\)\*\.25/);
assert.match(shader, /probeSceneUv=projectUv\(sceneHit\+vec3\(worldOffset,0\.\)\)/);
assert.match(shader, /sampleScene\(probeSceneUv\)/);
assert.match(shader, /regionAtlasEvidence\(probeSceneUv,probe\.rgb,glassHit\)/);
assert.match(shader, /uRegionStrength\*clamp\(uStrength,0\.,1\.\)\*regionSurfaceValid/);
assert.match(shader, /worldDelta\+=boundedIncidence\*cellEnvelope\*cellDimensions\*signedCurvatureScale\(\)\*\.18/);
assert.match(paletteShader, /for\(int candidateIndex=0;candidateIndex<5;candidateIndex\+\+\)/);
assert.match(paletteShader, /if\(candidateIndex==1\)cut=3\.;else if\(candidateIndex==2\)cut=5\.;else if\(candidateIndex==3\)cut=2\.;else if\(candidateIndex==4\)cut=6\./);
assert.match(paletteShader, /candidateCost=sideColorCost\(weightedVotesA,colorA,c0,c1,c2,c3\)\+sideColorCost\(weightedVotesB,colorB,c0,c1,c2,c3\)/);
assert.match(paletteShader, /weightedVotesA\+=oneHot\*sampleWeight/);
assert.match(paletteShader, /sideColorCost\(weightedVotesA,colorA,c0,c1,c2,c3\)/);
assert.match(paletteShader, /if\(pairCost<bestCost\)/);
assert.match(paletteShader, /smoothstep\(\.55,\.75,accuracy\)\*smoothstep\(\.015625,\.078125,gain\)/);
assert.match(paletteShader, /a!=b&&votesA\[a\]>0\.&&votesB\[b\]>0\./);
assert.match(paletteShader, /float confidence=\.85\+\.15\*oldConfidence/);
assert.match(paletteShader, /confidence=mix\(confidence,1\.,smoothstep\(\.02,\.06,cellEvidence\/64\.\)\)/);
assert.ok(paletteShader.includes('metadata=vec4((bestAxis*8.+bestCut)/255.,bestA/255.,bestB/255.,bestConfidence);'));
const cuts = [4, 3, 5, 2, 6];
const max4 = counts => Math.max(...counts);
const palette = [0, 0.30, 0.70, 1].map(value => [value, value, value]);
const distanceColor = (a, b) => {
const d = a.map((value, i) => value - b[i]);
const y = d[0] * 0.299 + d[1] * 0.587 + d[2] * 0.114;
return d.reduce((sum, value) => sum + value * value, 0) + 0.35 * y * y;
};
function sideColorCost(votes, target) {
return votes.reduce((sum, count, label) => sum + count * distanceColor(palette[label], palette[target]), 0);
}
const smoothstep = (a, b, x) => {
const t = Math.max(0, Math.min(1, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
};
function fitSplit(labels) {
const whole = [0, 0, 0, 0];
for (const label of labels) whole[label]++;
const singleMajority = max4(whole) / 64;
let best = null, bestCost = Infinity;
for (let axis = 0; axis < 2; axis++) for (const cut of cuts) {
const sideA = [0, 0, 0, 0], sideB = [0, 0, 0, 0];
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) {
const label = labels[y * 8 + x];
const isA = axis === 0 ? x + 0.5 < cut : y + 0.5 < cut;
(isA ? sideA : sideB)[label]++;
}
let pair = null, pairCost = Infinity;
for (let labelA = 0; labelA < 4; labelA++) for (let labelB = 0; labelB < 4; labelB++) {
if (labelA === labelB || sideA[labelA] === 0 || sideB[labelB] === 0) continue;
const cost = sideColorCost(sideA, labelA) + sideColorCost(sideB, labelB);
if (cost < pairCost) { pairCost = cost; pair = { labelA, labelB }; }
}
if (pair && pairCost < bestCost) {
bestCost = pairCost;
const accuracy = (sideA[pair.labelA] + sideB[pair.labelB]) / 64;
const gain = accuracy - singleMajority;
const oldConfidence = smoothstep(0.55, 0.75, accuracy) * smoothstep(0.015625, 0.078125, gain);
best = { axis, cut, ...pair, accuracy, gain, cost: pairCost, confidence: 0.85 + 0.15 * oldConfidence };
}
}
return best;
}
function encodeDecode({ axis, cut, labelA, labelB, confidence }) {
const rgba = [(axis * 8 + cut) / 255, labelA / 255, labelB / 255, confidence]
.map((value, index) => index === 3 ? value : Math.round(value * 255) / 255);
const packed = Math.floor(rgba[0] * 255 + 0.5);
return { axis: Math.floor(packed / 8), cut: packed % 8,
labelA: Math.floor(rgba[1] * 255 + 0.5), labelB: Math.floor(rgba[2] * 255 + 0.5), confidence: rgba[3] };
}
function splitFixture(axis, cut, labelA = 0, labelB = 1) {
return Array.from({ length: 64 }, (_, index) => {
const x = index % 8, y = Math.floor(index / 8);
return (axis === 0 ? x + 0.5 < cut : y + 0.5 < cut) ? labelA : labelB;
});
}
function darkSafeColor(sourceLuminance, ownIndex, paletteLuminance, candidateIndex) {
let assignedIndex = candidateIndex;
if (sourceLuminance < 0.20 && paletteLuminance[candidateIndex] - sourceLuminance > 0.16) {
assignedIndex = ownIndex;
if (paletteLuminance[ownIndex] - sourceLuminance > 0.16) {
let darkestIndex = 0;
for (let index = 1; index < paletteLuminance.length; index++) {
if (paletteLuminance[index] < paletteLuminance[darkestIndex]) darkestIndex = index;
}
assignedIndex = paletteLuminance[darkestIndex] <= sourceLuminance + 0.16 ? darkestIndex : null;
}
}
return assignedIndex;
}
for (const [axis, cut] of [[0, 2], [0, 6], [1, 2], [1, 6]]) {
const model = fitSplit(splitFixture(axis, cut, 2, 3));
assert.equal(model.axis, axis, `axis mismatch for split ${axis}/${cut}`);
assert.equal(model.cut, cut, `cut mismatch for split ${axis}/${cut}`);
assert.equal(model.accuracy, 1, `exact split should explain every sample for ${axis}/${cut}`);
const decoded = encodeDecode(model);
assert.deepEqual([decoded.axis, decoded.cut, decoded.labelA, decoded.labelB], [axis, cut, 2, 3], 'RGBA metadata must round-trip');
}
const checkerboard = Array.from({ length: 64 }, (_, index) => ((index % 8) + Math.floor(index / 8)) % 2);
const checkerboardModel = fitSplit(checkerboard);
assert.ok(checkerboardModel, 'a cell with two observed labels on both sides can use the two-color model');
assert.notEqual(checkerboardModel.labelA, checkerboardModel.labelB);
assert.equal(checkerboardModel.accuracy, 0.5, 'checkerboard split should reflect its measured pair accuracy');
assert.equal(checkerboardModel.confidence, 0.85, 'valid two-color model starts at the documented confidence floor');
assert.equal(fitSplit(Array(64).fill(2)), null, 'a single-label cell must not invent a split');
const darkSource = 0.10;
assert.equal(darkSafeColor(darkSource, 1, [0.70, 0.18, 0.30, 0.44], 0), 1,
'a sufficiently dark own-palette color should preserve the observed dark shape');
assert.equal(darkSafeColor(darkSource, 1, [0.70, 0.40, 0.12, 0.44], 0), 2,
'otherwise use the darkest palette color within the source luminance tolerance');
assert.equal(darkSafeColor(darkSource, 1, [0.70, 0.40, 0.31, 0.44], 0), null,
'retain original pixel RGB only if no palette color can safely represent it');
assert.equal(darkSafeColor(0.25, 1, [0.70, 0.40, 0.31, 0.44], 0), 0,
'ordinary and non-dark source pixels follow the selected color without protection');
const skewedVotes = [6, 5, 0, 4];
assert.equal(skewedVotes.indexOf(max4(skewedVotes)), 0, 'fixture has a count majority at label 0');
assert.equal([0, 1, 2, 3].reduce((best, label) => sideColorCost(skewedVotes, label) < sideColorCost(skewedVotes, best) ? label : best, 0), 1,
'SSE should choose the color representative closer to the full observed distribution');
const nearDarkVotes = [8, 3, 0, 4];
const nearDarkWeighted = nearDarkVotes.map((count, label) => count * (label === 0 ? 4 : 1));
const sseWinner = votes => [0, 1, 2, 3].reduce((best, label) => sideColorCost(votes, label) < sideColorCost(votes, best) ? label : best, 0);
assert.equal(sseWinner(nearDarkVotes), 1, 'without near-dark evidence, the ordinary SSE representative wins');
assert.equal(sseWinner(nearDarkWeighted), 0, 'near-dark evidence changes only the weighted SSE representative');
const evidenceConfidence = (confidence, evidence) => confidence + (1 - confidence) * smoothstep(0.02, 0.06, evidence);
assert.equal(evidenceConfidence(0.85, 0), 0.85, 'far or invalid evidence leaves ordinary confidence unchanged');
assert.equal(evidenceConfidence(0.85, 0.06), 1, 'enough near-dark evidence raises confidence to full strength');
assert.ok(evidenceConfidence(0.85, 0.04) > 0.85 && evidenceConfidence(0.85, 0.04) < 1,
'partial near-dark evidence increases confidence smoothly');
const radial = [0.2, 0.4, 0.6], split = [0.8, 0.4, 0.2], conf = 0.5;
assert.deepEqual(radial.map((value, i) => value * (1 - conf) + split[i] * conf), [0.5, 0.4, 0.4], 'confidence blends split with radial fallback');
console.log('Region axis-split checks passed: SSE axis/cut/pair scoring, vertical/horizontal cuts 2/6, observed side-label pairs, checkerboard model, single-label fallback, metadata round-trip, confidence blending, dark eye/nose protection, and edge-independent color strength.');