test: real shader/terrain tests + headless render check
npm test was a brace counter: it checked each file was >10 chars and that {
and } counts matched. It passed on a build whose shader would not compile.
- tests/shaders.test.js compiles the game's real GLSL with glslangValidator
(Khronos' reference compiler) under a three.js-style attribute prefix, and
includes a canary that asserts a known-bad vec4 construction is REJECTED, so
the suite cannot pass vacuously.
- tests/terrain.test.js covers heightfield determinism (the "persistent world"
claim), airport flattening inside the 200 m radius with no leakage outside it,
out-of-bounds behaviour, plausible height range, and vertex colour counts.
- scripts/headless-check.mjs (npm run test:render) serves dist/, drives the
game over raw CDP with SwiftShader software WebGL, and fails if the canvas is
black, the shaders fail to compile, or the scene does not change after input.
No new dependencies: Node 22 built-in fetch/WebSocket. 120 s watchdog so it
cannot hang a runner.
- npm test -> node --test "tests/*.test.js"; old checker kept as npm run lint:syntax.
- CI installs glslang-tools and runs the real tests; render job is
continue-on-error because not every runner has a Chrome binary.
Verified non-vacuous: re-breaking the shader makes the unit tests fail 2/4 with
glslang's own "constructor: too many arguments" and makes the render check fail
four ways (1 distinct colour, 0/64000 lit pixels, shader error, frozen scene).
This commit is contained in:
1 parent
04e3400ab9
commit
ee24276fd0
6 files changed
+528
-13
No files matched your search
@@ -0,0 +1,95 @@
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Compiles the game's real GLSL with Khronos' reference compiler (glslangValidator).
|
||||
// This is the test that would have caught the vec4(vec3, float, float) bug: the shader
|
||||
// failed to compile in the browser and the 3D canvas rendered pure black.
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const retroSrc = readFileSync(join(root, 'js', 'retro.js'), 'utf8');
|
||||
|
||||
function glslangAvailable() {
|
||||
try {
|
||||
execFileSync('glslangValidator', ['--version'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// three.js prepends these declarations for a GLSL ES 1.00 ShaderMaterial.
|
||||
const VERTEX_PREFIX = `precision highp float;
|
||||
attribute vec3 position;
|
||||
attribute vec3 normal;
|
||||
attribute vec2 uv;
|
||||
uniform mat4 modelViewMatrix;
|
||||
uniform mat4 projectionMatrix;
|
||||
uniform mat3 normalMatrix;
|
||||
`;
|
||||
const FRAGMENT_PREFIX = 'precision highp float;\n';
|
||||
|
||||
function extractShader(source, name) {
|
||||
const re = new RegExp(`${name}:\\s*\`([\\s\\S]*?)\``, 'm');
|
||||
const m = source.match(re);
|
||||
if (!m) throw new Error(`could not extract ${name} from js/retro.js`);
|
||||
return m[1];
|
||||
}
|
||||
|
||||
function compile(source, stage, label) {
|
||||
// glslang infers the shader stage from the file extension, so the extension matters.
|
||||
const ext = stage === 'vert' ? 'vert' : 'frag';
|
||||
const file = join(tmpdir(), `shader-test-${label}.${ext}`);
|
||||
writeFileSync(file, `#version 100\n${source}`);
|
||||
try {
|
||||
execFileSync('glslangValidator', [file], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
return { ok: true, log: '' };
|
||||
} catch (e) {
|
||||
const log = `${e.stdout || ''}${e.stderr || ''}`;
|
||||
// A usage banner means glslang never read the file: that is a harness fault, not a
|
||||
// shader fault, and must not be mistaken for a compile error.
|
||||
if (/^Usage: glslang/m.test(log.trim())) {
|
||||
throw new Error(`glslang invoked incorrectly (no stage recognised) for ${label}.${ext}`);
|
||||
}
|
||||
return { ok: false, log };
|
||||
}
|
||||
}
|
||||
|
||||
describe('CRT post-processing shaders', () => {
|
||||
const vertex = extractShader(retroSrc, 'vertexShader');
|
||||
const fragment = extractShader(retroSrc, 'fragmentShader');
|
||||
|
||||
test('both shaders are present in js/retro.js', () => {
|
||||
assert.ok(vertex.includes('gl_Position'), 'vertex shader has no gl_Position assignment');
|
||||
assert.ok(fragment.includes('gl_FragColor'), 'fragment shader has no gl_FragColor assignment');
|
||||
});
|
||||
|
||||
test('shaders compile as GLSL ES 1.00', { skip: glslangAvailable() ? false : 'glslangValidator not installed (apt: glslang-tools)' }, () => {
|
||||
const v = compile(VERTEX_PREFIX + vertex, 'vert', 'vertex');
|
||||
assert.ok(v.ok, `vertex shader failed to compile:\n${v.log}`);
|
||||
const f = compile(FRAGMENT_PREFIX + fragment, 'frag', 'fragment');
|
||||
assert.ok(f.ok, `fragment shader failed to compile:\n${f.log}`);
|
||||
});
|
||||
|
||||
// Canary: proves the harness can actually reject a bad shader, so the test above
|
||||
// cannot pass vacuously.
|
||||
test('harness rejects a known-bad vec4 construction', { skip: glslangAvailable() ? false : 'glslangValidator not installed (apt: glslang-tools)' }, () => {
|
||||
const bad = `${VERTEX_PREFIX}varying vec2 vUv;\nvoid main(){ vUv = uv; gl_Position = vec4(position, 0.0, 1.0); }`;
|
||||
const r = compile(bad, 'vert', 'canary');
|
||||
assert.equal(r.ok, false, 'canary shader unexpectedly compiled — the test harness is not validating anything');
|
||||
assert.match(r.log, /constructor/, 'canary rejected for an unexpected reason');
|
||||
});
|
||||
|
||||
test('vertex shader does not over-fill gl_Position', () => {
|
||||
// position is a vec3 in three.js, so vec4(position, x, y) is 5 components into a vec4.
|
||||
const offenders = vertex
|
||||
.split('\n')
|
||||
.map((line, i) => ({ line: line.trim(), i: i + 1 }))
|
||||
.filter(l => /vec4\(\s*position\s*,\s*[\d.]+\s*,\s*[\d.]+\s*\)/.test(l.line));
|
||||
assert.deepEqual(offenders, [], `vec4(vec3, float, float) is invalid GLSL ES: ${JSON.stringify(offenders)}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Terrain } from '../js/terrain.js';
|
||||
|
||||
describe('Terrain', () => {
|
||||
test('heightfield is deterministic across instances (persistent worlds)', () => {
|
||||
const a = new Terrain(2000, 100);
|
||||
const b = new Terrain(2000, 100);
|
||||
a.createMesh();
|
||||
b.createMesh();
|
||||
for (const [x, z] of [[0, 0], [317, -812], [-999, 999], [123.5, -45.25]]) {
|
||||
assert.equal(a.getHeightAt(x, z), b.getHeightAt(x, z), `height differs at ${x},${z}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('heightfield is deterministic across rebuilds of the same instance', () => {
|
||||
const t = new Terrain(2000, 100);
|
||||
t.createMesh();
|
||||
const first = t.getHeightAt(317, -812);
|
||||
t.createMesh();
|
||||
assert.equal(t.getHeightAt(317, -812), first);
|
||||
});
|
||||
|
||||
test('airport apron is flattened inside the radius and untouched outside it', () => {
|
||||
const rough = new Terrain(2000, 100);
|
||||
rough.buildHeightData(); // heightfield only, no flattening
|
||||
const flat = new Terrain(2000, 100);
|
||||
flat.createMesh(); // createMesh() flattens a 200 m radius
|
||||
|
||||
// The apron centre happens to be naturally flat in this world, so assert on the
|
||||
// strongest reduction found anywhere inside the radius rather than on one point.
|
||||
let best = null;
|
||||
for (let x = -190; x <= 190; x += 5) {
|
||||
for (let z = -190; z <= 190; z += 5) {
|
||||
if (Math.hypot(x, z) > 190) continue;
|
||||
const delta = rough.getHeightAt(x, z) - flat.getHeightAt(x, z);
|
||||
if (!best || delta > best.delta) best = { x, z, delta };
|
||||
}
|
||||
}
|
||||
assert.ok(best && best.delta > 1, `createMesh() did not flatten inside the 200 m radius (best delta: ${best && best.delta})`);
|
||||
|
||||
// Flattening must stop at the radius, not bleed into the wider world.
|
||||
for (const [x, z] of [[210, 0], [0, 210], [300, 300], [-400, 250]]) {
|
||||
assert.equal(flat.getHeightAt(x, z), rough.getHeightAt(x, z), `flattening leaked outside the radius at ${x},${z}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('height outside the heightfield bounds is zero, not garbage', () => {
|
||||
const t = new Terrain(2000, 100);
|
||||
t.createMesh();
|
||||
for (const [x, z] of [[1500, 0], [0, 1500], [-1500, -1500]]) {
|
||||
assert.equal(t.getHeightAt(x, z), 0, `expected 0 outside bounds at ${x},${z}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('heights stay within a sane range for a 2000 m world', () => {
|
||||
const t = new Terrain(2000, 100);
|
||||
t.createMesh();
|
||||
let min = Infinity;
|
||||
let max = 0;
|
||||
for (let x = -990; x <= 990; x += 30) {
|
||||
for (let z = -990; z <= 990; z += 30) {
|
||||
const h = t.getHeightAt(x, z);
|
||||
min = Math.min(min, h);
|
||||
max = Math.max(max, h);
|
||||
}
|
||||
}
|
||||
assert.ok(min >= 0, `negative terrain height: ${min}`);
|
||||
assert.ok(max > 10 && max < 200, `implausible max terrain height: ${max}`);
|
||||
});
|
||||
|
||||
test('mesh has one vertex colour per vertex', () => {
|
||||
const t = new Terrain(2000, 50);
|
||||
const mesh = t.createMesh();
|
||||
const pos = mesh.geometry.attributes.position;
|
||||
const col = mesh.geometry.attributes.color;
|
||||
assert.equal(col.count, pos.count, 'colour attribute count does not match position count');
|
||||
assert.equal(col.itemSize, 3);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user