diff --git a/src/atlas-loader.js b/src/atlas-loader.js index ed04885..2d1e8da 100644 --- a/src/atlas-loader.js +++ b/src/atlas-loader.js @@ -51,8 +51,8 @@ function loadAtlasDirs(dirs) { const spec = loadAtlasSpec(specPath); if (registry[spec.atlas_id]) { warnings.push( - `duplicate atlas_id '${spec.atlas_id}' at ${specPath}; ` + - `keeping earlier registration from ${registry[spec.atlas_id].specPath}` + `atlas_id '${spec.atlas_id}' is a duplicate; ` + + `${specPath} ignored, keeping earlier registration from ${registry[spec.atlas_id].specPath}` ); continue; } diff --git a/src/cli.js b/src/cli.js index 3ed76cb..f1b037d 100644 --- a/src/cli.js +++ b/src/cli.js @@ -32,6 +32,7 @@ function run(argv, { out, err }) { const handlers = { encode: require('./commands/encode'), decode: require('./commands/decode'), + build: require('./commands/build'), }; const handler = handlers[head]; if (!handler) { diff --git a/src/commands/build.js b/src/commands/build.js new file mode 100644 index 0000000..ac47bc2 --- /dev/null +++ b/src/commands/build.js @@ -0,0 +1,94 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { parseDsl } = require('../dsl-parser'); +const { loadAtlasDirs } = require('../atlas-loader'); +const { buildMap } = require('../builder'); + +const USAGE = 'usage: sporel-mapper build [--atlas-dir ...]\n'; + +function parseArgs(args) { + const positional = []; + const atlasDirs = []; + let i = 0; + while (i < args.length) { + const a = args[i]; + if (a === '--atlas-dir') { + if (i + 1 >= args.length) { + throw new Error(`--atlas-dir requires a path`); + } + atlasDirs.push(args[i + 1]); + i += 2; + } else { + positional.push(a); + i += 1; + } + } + return { positional, atlasDirs }; +} + +function run(args, { out, err }) { + let parsed; + try { + parsed = parseArgs(args); + } catch (e) { + err.write(`error: ${e.message}\n${USAGE}`); + return 1; + } + if (parsed.positional.length !== 2) { + err.write(USAGE); + return 1; + } + const [specPath, outPath] = parsed.positional; + + let dslText; + try { + dslText = fs.readFileSync(specPath, 'utf8'); + } catch (e) { + err.write(`error: cannot read ${specPath}: ${e.message}\n`); + return 1; + } + + let ast; + try { + ast = parseDsl(dslText); + } catch (e) { + const line = e.line || '?'; + err.write(`${specPath}:${line}: ${e.message}\n`); + return 1; + } + + let registry; + try { + const result = loadAtlasDirs(parsed.atlasDirs); + registry = result.registry; + for (const w of result.warnings) { + err.write(`warning: ${w}\n`); + } + } catch (e) { + err.write(`error: ${e.message}\n`); + return 1; + } + + const defaultId = path.basename(outPath, path.extname(outPath)); + let map; + try { + map = buildMap(ast, registry, { defaultId }); + } catch (e) { + const line = e.line || '?'; + err.write(`${specPath}:${line}: ${e.message}\n`); + return 1; + } + + try { + fs.writeFileSync(outPath, JSON.stringify(map, null, 2) + '\n'); + } catch (e) { + err.write(`error: cannot write ${outPath}: ${e.message}\n`); + return 1; + } + + return 0; +} + +module.exports = { run }; diff --git a/tests/build.test.js b/tests/build.test.js new file mode 100644 index 0000000..8d07249 --- /dev/null +++ b/tests/build.test.js @@ -0,0 +1,98 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { run } = require('../src/cli'); + +class Sink { + constructor() { this.chunks = []; } + write(s) { this.chunks.push(s); } + get text() { return this.chunks.join(''); } +} + +const FIX_ATLAS = path.join(__dirname, 'fixtures', 'atlases'); +const FIX_DSL = path.join(__dirname, 'fixtures', 'dsl'); + +function tmpOut(name) { + return path.join(os.tmpdir(), `${name}.json`); +} + +test('build: minimal DSL → valid v2 JSON file', () => { + const out = new Sink(); const err = new Sink(); + const outPath = tmpOut('minimal'); + try { + const code = run(['build', path.join(FIX_DSL, 'minimal.txt'), outPath, '--atlas-dir', FIX_ATLAS], { out, err }); + assert.equal(code, 0, err.text); + const json = JSON.parse(fs.readFileSync(outPath, 'utf8')); + assert.equal(json.schema_version, 2); + assert.equal(json.id, 'minimal'); // derived from out filename + assert.deepEqual(json.size, { w: 4, h: 4 }); + assert.deepEqual(json.atlases, ['atlas_a']); + assert.equal(json.layers.surface.tiles.length, 16); + } finally { + if (fs.existsSync(outPath)) fs.unlinkSync(outPath); + } +}); + +test('build: full DSL with id directive overrides filename-derived id', () => { + const out = new Sink(); const err = new Sink(); + const outPath = tmpOut('full'); + try { + const code = run(['build', path.join(FIX_DSL, 'full.txt'), outPath, '--atlas-dir', FIX_ATLAS], { out, err }); + assert.equal(code, 0, err.text); + const json = JSON.parse(fs.readFileSync(outPath, 'utf8')); + assert.equal(json.id, 'full_demo'); + assert.deepEqual(Object.keys(json.layers).sort(), ['surface', 'wall']); + assert.equal(json.roof.length, 16); + } finally { + if (fs.existsSync(outPath)) fs.unlinkSync(outPath); + } +}); + +test('build: out-of-bounds DSL reports file:line on stderr, exit 1', () => { + const out = new Sink(); const err = new Sink(); + const outPath = tmpOut('err'); + const inPath = path.join(FIX_DSL, 'error_oob.txt'); + const code = run(['build', inPath, outPath, '--atlas-dir', FIX_ATLAS], { out, err }); + assert.equal(code, 1); + assert.match(err.text, /error_oob\.txt:4:/); + assert.match(err.text, /out of bounds/); + // Output file must NOT exist + assert.equal(fs.existsSync(outPath), false); +}); + +test('build: missing --atlas-dir for DSL that needs atlases → error', () => { + const out = new Sink(); const err = new Sink(); + const outPath = tmpOut('noatlas'); + const code = run(['build', path.join(FIX_DSL, 'minimal.txt'), outPath, '--atlas-dir', '/no/such/dir'], { out, err }); + assert.equal(code, 1); + assert.match(err.text, /error/i); + if (fs.existsSync(outPath)) fs.unlinkSync(outPath); +}); + +test('build: insufficient positional args prints usage, exit 1', () => { + const out = new Sink(); const err = new Sink(); + const code = run(['build', 'only_one_arg.txt'], { out, err }); + assert.equal(code, 1); + assert.match(err.text, /usage: sporel-mapper build/); +}); + +test('build: duplicate atlas across --atlas-dir paths warns on stderr but exits 0', () => { + // Use both fixture dirs (atlases + atlases-dup) + const FIX_DUP = path.join(__dirname, 'fixtures', 'atlases-dup'); + const out = new Sink(); const err = new Sink(); + const outPath = tmpOut('dup'); + try { + const code = run( + ['build', path.join(FIX_DSL, 'minimal.txt'), outPath, '--atlas-dir', FIX_ATLAS, '--atlas-dir', FIX_DUP], + { out, err } + ); + assert.equal(code, 0, err.text); + assert.match(err.text, /warning.*atlas_a.*duplicate/i); + } finally { + if (fs.existsSync(outPath)) fs.unlinkSync(outPath); + } +});