diff --git a/CHANGELOG.md b/CHANGELOG.md index b2c32ff..363569a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this package. Versions follow [Semantic Versioning](https ## Unreleased +### Site products + +- Load live variants from entry manifests and accept variant paths in read-set requests. +- Validate product chart aliases, language-indexed UI references and offered variants. +- Honor audio-disabled story products and select an available playback language from story listings. + ### Story products - Story loading, playback and authoring use `nnnotes.product/1` manifests with shared object resources and language-specific UI and fonts. diff --git a/docs/data-format.md b/docs/data-format.md index 15e53b6..18c5435 100644 --- a/docs/data-format.md +++ b/docs/data-format.md @@ -982,3 +982,9 @@ further and run lives and charts through the player in Node (see [CONTRIBUTING.m The song catalog example reads `music-data.json` with format `nnnotes.music-data/1`. Replay presets read `nnnotes.deck-data/1` for the caller-provided deck capabilities, and the replay worker accepts `nnnotes.replay-manifest/1` references. These are separate inputs from the live, model and story product manifests. + +## Site entry products + +A chart entry under `charts/` can be an `nnnotes.product/1` live manifest. Its optional `variants` lists immutable note-skin, note-effect and sound products relative to its declared root; the loader combines them with the required parts. `options.LiveQuality` lists the offered qualities. `scripts/read-set.mjs --variants=` supplies extra manifest paths for either plan or full mode. + +The live's `parts.ui` maps languages to UI products. An `audio: false` entry omits sound streams while retaining cue metadata. Story sessions use silent timing for such entries. Story listings select a playback language for which the entry includes resources. diff --git a/examples/chart-list/chart-list.js b/examples/chart-list/chart-list.js index e4e3c9b..ab6c4d3 100644 --- a/examples/chart-list/chart-list.js +++ b/examples/chart-list/chart-list.js @@ -29,11 +29,10 @@ const loadIndex = async () => { const play = async (music, difficulty) => { if (!/^\d+$/.test(music) || !DIFFICULTIES.includes(difficulty)) throw new Error("unknown chart"); - let manifest = `charts/${music}_${difficulty}.json`; - if (q.has("region")) { // a region's own manifest, when it has one - const index = await loadIndex().catch(() => null); - if (index) manifest = manifestFor(index, music, difficulty, pickRegion(index, q.get("region"))); - } + const index = await loadIndex().catch(() => null); + const region = index ? pickRegion(index, q.get("region")) : null; + const manifest = index ? manifestFor(index, music, difficulty, region) : `charts/${music}_${difficulty}.json`; + const entry = index && chartsFor(index, region).find(c => c.id === `${music}_${difficulty}`); document.body.className = "play"; const el = document.createElement("ournotes-player"); el.controls = true; @@ -42,7 +41,7 @@ const play = async (music, difficulty) => { if (musicOff) el.music = false; if (off(q.get("se"))) el.se = false; el.addEventListener("ready", () => { - const c = el.chart; + const c = entry || el.chart; const title = c && chartText(c, q.get("lang")).title; if (title) document.title = `${title} ${difficulty.toUpperCase()}`; el.focus(); // keyboard controls without a first click diff --git a/examples/story-list/listing.js b/examples/story-list/listing.js index a1f7f2c..6d7331d 100644 --- a/examples/story-list/listing.js +++ b/examples/story-list/listing.js @@ -43,6 +43,13 @@ export const nameIn = (names, lang, fallback) => { // The title of an entry in `lang` (else its manifest's default language, else any), or "#". export const storyTitle = (s, lang) => nameIn(s.titles, lang, s.language) || `#${s.advId}`; +// A language with playback resources for this entry, including when its listing has more title languages. +export const playbackLanguage = (entry, wanted) => { + if (!Array.isArray(entry?.languages)) return wanted; + if (entry.languages.includes(wanted)) return wanted; + return entry.languages.includes(entry.language) ? entry.language : entry.languages[0]; +}; + // The kind of the entry's first group ("chapter", "friendship", "spotTalk", "liveResult", "spot"), or "other". export const storyKind = (s) => (Array.isArray(s.groups) && s.groups.length ? s.groups[0].kind : "other"); diff --git a/examples/story-list/story-list.js b/examples/story-list/story-list.js index 43fd148..a748333 100644 --- a/examples/story-list/story-list.js +++ b/examples/story-list/story-list.js @@ -5,7 +5,7 @@ // story's. ?kind= lists the stories of one group kind. // Served from the repository. On a site, import dist/ournotes-player.story.element.min.js instead. import "../../src/story/define.js"; -import { firstLoad, groupLabel, languagesFor, manifestFor, pickLanguage, pickRegion, queryString, storiesFor, storyKind, +import { firstLoad, groupLabel, languagesFor, manifestFor, pickLanguage, pickRegion, playbackLanguage, queryString, storiesFor, storyKind, storyTitle } from "./listing.js"; const CORE = "https://cubism.live2d.com/sdk-web/cubismcore/live2dcubismcore.min.js"; @@ -84,7 +84,10 @@ const play = async (advId) => { await loadScript(q.get("core") || CORE, "Live2DCubismCore"); if (q.get("motionsync")) await loadScript(q.get("motionsync"), "Live2DCubismMotionSyncCore"); if (q.get("spine")) await loadScript(q.get("spine"), "spine"); - for (const a of ["lang", "ui-lang", "line", "speed", "quality", "film-grain"]) if (q.get(a)) story.setAttribute(a, q.get(a)); + const entry = index?.stories.find(s => s.manifest === manifest); + const language = playbackLanguage(entry, q.get("lang")); + if (language) story.lang = language; + for (const a of ["ui-lang", "line", "speed", "quality", "film-grain"]) if (q.get(a)) story.setAttribute(a, q.get(a)); if (q.get("auto")) story.setAttribute("auto", q.get("auto")); story.src = new URL(manifest, site).href; }; diff --git a/schema/live.schema.json b/schema/live.schema.json index 7857ce2..48b42b2 100644 --- a/schema/live.schema.json +++ b/schema/live.schema.json @@ -61,6 +61,24 @@ "additionalProperties": { "type": "string", "pattern": "^[^/\\\\]+/[^/\\\\]+\\.json$" + }, + "properties": { + "ui": { + "type": "object", + "propertyNames": { + "enum": [ + "ja", + "en", + "zh-Hant", + "zh-Hans", + "ko" + ] + }, + "additionalProperties": { + "type": "string", + "pattern": "^[^/\\\\]+/[^/\\\\]+\\.json$" + } + } } } }, diff --git a/schema/product.schema.json b/schema/product.schema.json index 1657e58..b53052e 100644 --- a/schema/product.schema.json +++ b/schema/product.schema.json @@ -76,6 +76,38 @@ "zh-Hans", "ko" ] + }, + "options": { + "type": "object", + "description": "Optional: Live option name -> the values the files support (options that select files).", + "properties": { + "LiveQuality": { + "type": "array", + "items": { + "enum": [ + 0, + 1, + 2 + ] + }, + "minItems": 1 + } + }, + "additionalProperties": { + "type": "array", + "minItems": 1 + } + }, + "variants": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "audio": { + "type": "boolean", + "description": "Whether audio streams are included." } }, "$defs": { diff --git a/schema/story-manifest.schema.json b/schema/story-manifest.schema.json index db4bbf0..2d737bc 100644 --- a/schema/story-manifest.schema.json +++ b/schema/story-manifest.schema.json @@ -67,6 +67,10 @@ "type": "string", "minLength": 1 }, + "audio": { + "type": "boolean", + "description": "Whether audio streams are included." + }, "language": { "enum": [ "ja", diff --git a/scripts/read-set.mjs b/scripts/read-set.mjs index 7be4807..5536566 100644 --- a/scripts/read-set.mjs +++ b/scripts/read-set.mjs @@ -26,7 +26,7 @@ // loads. // // node scripts/read-set.mjs [--plan] [--files=] [--keys] [--frames=] [--quality=<0..2>] -// [--settings=] +// [--settings=] [--variants=] // node scripts/read-set.mjs --serve // node scripts/read-set.mjs --features // a live manifest (lives/_.json of a product layout), a chart manifest @@ -38,12 +38,13 @@ // ("#", "#."); in plan mode the keys read by the load and the plan // --frames full mode: stop after n frames instead of at the end of the chart (not with --plan) // --settings the Live options of the session, a JSON object ({"MirrorChart": true, "NoteSpeed": 9}) +// --variants additional product manifest paths for note skins, effects or sounds, relative to the site root // --serve requests on stdin, one JSON object per line: {"chart": , "plan"?, "files"?, "keys"?, -// "frames"?, "quality"?, "settings"?} (the options above; "settings" as an object); one answer per +// "frames"?, "quality"?, "settings"?, "variants"?} (the options above; "settings" as an object); one answer per // request on stdout, one JSON object per line: {"ok": true, "result": , "cpuSeconds"} // or {"ok": false, "error", "cpuSeconds"} (cpuSeconds: CPU time of the process for the request); exits at // the end of stdin -// --features prints {"features": [...]}: the modes beyond the full simulation ("plan", "serve", "settings") +// --features prints {"features": [...]}: the modes beyond the full simulation ("plan", "serve", "settings", "variants") // Prints one JSON object: {chart, mode, frames, state, readAfterLoad, read, seconds, settings?, files | listed + // missing + extra, keys?}; mode "full" (state "ended" at the end of the chart) or "plan" (state "planned", frames 0); // settings: the options asked for, when given. Exit 2 for a usage error or settings the chart does not accept. @@ -53,9 +54,9 @@ import { ChartSession } from "../src/live/session.js"; import { LiveSettingsError } from "../src/live/settings.js"; import { HeadlessAudioContext, headlessGL, openChart } from "./lib/headless.mjs"; -const FEATURES = ["plan", "serve", "settings"]; +const FEATURES = ["plan", "serve", "settings", "variants"]; const USAGE = "usage: node scripts/read-set.mjs [--plan] [--files=] [--keys] [--frames=] " + - "[--quality=] [--settings=] | --serve | --features"; + "[--quality=] [--settings=] [--variants=] | --serve | --features"; class UsageError extends Error {} class NoPlan extends Error {} @@ -71,12 +72,25 @@ const settingsOf = (v) => { return o; }; +const variantsOf = (value) => { + if (value === undefined || value === null) return []; + let paths = value; + if (typeof paths === "string") { + try { paths = JSON.parse(paths); } catch (e) { throw new UsageError(`--variants: ${e.message}`); } + } + if (!Array.isArray(paths) || paths.some(p => typeof p !== "string" || !p.endsWith(".json") || /[:?#\\]/.test(p) + || p.split("/").length < 2 || p.split("/").some(s => !s || s === "." || s === ".."))) + throw new UsageError("--variants must be an array of relative product manifest paths"); + return paths; +}; + // the read set of one chart: {out, bad}; bad: a --files comparison found a difference const readSet = async (where, opt) => { if (opt.plan && opt.frames !== undefined) throw new UsageError("--frames has no meaning with --plan"); const settings = settingsOf(opt.settings); + const variants = variantsOf(opt.variants); const t0 = Date.now(); - const assets = await openChart(where); + const assets = await openChart(where, { variants }); if (!assets.info) assets.info = { options: { LiveQuality: [0, 1, 2] } }; // a live directory (see the header) const reads = new Set(), keyReads = new Set(); const recorded = (name) => { const f = assets[name].bind(assets); assets[name] = (p, ...a) => { reads.add(p); return f(p, ...a); }; }; diff --git a/scripts/validate-data.mjs b/scripts/validate-data.mjs index 7ed1111..c5d2264 100644 --- a/scripts/validate-data.mjs +++ b/scripts/validate-data.mjs @@ -39,7 +39,7 @@ import { AssetStore, TEXT_ASSET, TEXT_FILE } from "../src/data/assets.js"; import { LiveData } from "../src/live/data.js"; import { legacyLive } from "../src/live/legacy.js"; import { LiveOptionContext } from "../src/live/settings.js"; -import { LIVE_PARTS, LIVE_PRODUCT, liveManifest, productDir } from "../src/live/store.js"; +import { LIVE_PARTS, LIVE_PRODUCT, LIVE_VARIANTS, liveManifest, productDir } from "../src/live/store.js"; import { storyPartRequests } from "../src/story/store.js"; import { StoryData } from "../src/story/data.js"; import { StoryPlayerCore } from "../src/story/player-core.js"; @@ -753,7 +753,10 @@ class Product extends Chart { // the manifest's name (its path without the directory and .json) is its subject with "/" as "_" checkName() { if (this.immutable) return; - const name = path.posix.basename(this.where, ".json"), want = String(this.man.subject).replaceAll("/", "_"); + const name = path.posix.basename(this.where, ".json"); + const subject = this.man.product === LIVE_PRODUCT && this.where.startsWith("charts/") + ? String(this.man.subject).split("/").slice(0, 2).join("/") : String(this.man.subject); + const want = subject.replaceAll("/", "_"); if (name !== want) this.err("manifest", `subject ${this.man.subject}: the manifest's name is ${name}, not ${want}`); } @@ -979,7 +982,7 @@ class Part extends Product { if (this.errs.length) return this.errs; const dir = productDir(this.where); const files = Object.fromEntries(Object.entries(m.files).map(([p, f]) => [`${dir}/${p}`, f])); - const C = new LiveChecks(this.site, new SiteStore(this.site, files, m.objects), (w, msg) => this.err(w, msg)); + const C = new LiveChecks(this.site, new SiteStore(this.site, files, m.objects, { audio: m.audio }), (w, msg) => this.err(w, msg)); C.part(product, dir); C.objects(); return this.errs; @@ -1040,6 +1043,15 @@ class Live extends Product { r.shown = true; } else loaded.push([productDir(p), r.man]); } + for (const p of m.variants || []) { + try { + const variant = JSON.parse(fs.readFileSync(this.site.file(p), "utf8")); + if (!LIVE_VARIANTS.includes(variant.product)) { this.err(p, "not a live variant product"); continue; } + const r = this.site.part(p, variant.product); + for (const e of r.errs) this.err(p, e); + if (!r.errs.length) loaded.push([productDir(p), r.man]); + } catch (e) { this.err(p, e.code === "ENOENT" ? "missing" : e.message); } + } if (this.errs.length) return this.errs; let merged; try { merged = liveManifest(loaded); } catch (e) { this.err("live", e.message); return this.errs; } @@ -1269,6 +1281,7 @@ class Story extends Product { if (!Array.isArray(cue.categories) || cue.categories.some((c) => typeof c !== "string")) this.err(where, "categories must be an array of names"); if ((cue.loopStart == null) !== (cue.loopEnd == null) || (cue.loopStart != null && !(cue.loopStart >= 0 && cue.loopStart < cue.loopEnd && cue.loopEnd <= cue.samples))) this.err(where, "loop points outside the waveform"); if (cue.encoderDelay !== undefined && (extOf(cue.file) !== "m4a" || !Number.isInteger(cue.encoderDelay) || cue.encoderDelay < 0)) this.err(where, "invalid encoderDelay"); + if (!d.hasSounds) continue; if (!this.store.has(cue.file)) this.err(where, `${cue.file} missing`); else { const error = C.ofFile(`story-wave|${cue.sampleRate}|${cue.samples}|${cue.channels}`, cue.file, (b) => waveCheck(b, cue)); if (error) this.err(where, error); } } @@ -1501,7 +1514,8 @@ function runAll(site, list, Kind) { } else { try { const man = JSON.parse(fs.readFileSync(site.file(manifest), "utf8")); - errs = new Kind(site, id, man, manifest).run(entry); + const Reader = Kind === Chart && man.schema === "nnnotes.product/1" ? Live : Kind; + errs = new Reader(site, id, man, manifest).run(entry); } catch (e) { errs = [`${manifest}: ${e.message}`]; } } if (errs.length) { diff --git a/src/live/store.js b/src/live/store.js index 3f8da0f..714d3a9 100644 --- a/src/live/store.js +++ b/src/live/store.js @@ -63,7 +63,7 @@ export const loadLiveStore = async (url, { variants = [], fetch = globalThis.fet if (typeof live.parts[part] !== "string") throw new Error(`${href}: live.json names no ${part} part`); wanted.push([live.parts[part], [product]]); } - for (const p of variants) wanted.push([String(p), LIVE_VARIANTS]); + for (const p of new Set([...(man.variants || []), ...variants])) wanted.push([String(p), LIVE_VARIANTS]); const loaded = await Promise.all(wanted.map(async ([p, products]) => { const u = new URL(p, root).href, m = await getJson(u); if (!products.some((x) => isProduct(m, x))) diff --git a/src/story/data.js b/src/story/data.js index 36ce8d0..7aeddf4 100644 --- a/src/story/data.js +++ b/src/story/data.js @@ -18,6 +18,8 @@ export class StoryData { this._docs = new Map(); } + get hasSounds() { return this.assets.info.audio !== false && Object.keys(this.parts.sounds || {}).length > 0; } + document(manifest, file, format = null) { const path = `${productDir(manifest)}/${file}`; if (!this._docs.has(path)) { diff --git a/src/story/session.js b/src/story/session.js index 5bb2d9b..d923091 100644 --- a/src/story/session.js +++ b/src/story/session.js @@ -181,7 +181,7 @@ export class StorySession { // empty) plays no sound, with the voices off as in the game without voice data (Session.WithVoice false: the // lines advance by their length, the speakers get the timed pseudo lip sync) const resolve = Audio.episodeResolver(episode, data); - const hasSounds = Object.keys(data.parts.sounds || {}).length > 0; + const hasSounds = data.hasSounds; const webAudio = hasSounds && opts.sound !== false && typeof globalThis.AudioContext === "function"; audio = this.audio = opts.audio ? opts.audio(resolve, loop, { assets: store }) : webAudio ? new Audio(resolve, loop, { context: opts.audioContext || null, assets: store }) diff --git a/src/story/simple/session.js b/src/story/simple/session.js index 0427f71..b5db872 100644 --- a/src/story/simple/session.js +++ b/src/story/simple/session.js @@ -178,7 +178,7 @@ export class SimpleStorySession { const resolve = Audio.episodeResolver(episode, data); // a story without its sound files (story.json `audio` empty) plays no sound, with the voices off as in the game // without voice data (as StorySession) - const hasSounds = Object.keys(data.parts.sounds || {}).length > 0; + const hasSounds = data.hasSounds; this.hasSounds = hasSounds; const webAudio = hasSounds && opts.sound !== false && typeof globalThis.AudioContext === "function"; const audioOptions = { assets: store }; diff --git a/tests/unit/live-store.test.mjs b/tests/unit/live-store.test.mjs index afe9fd1..d521a68 100644 --- a/tests/unit/live-store.test.mjs +++ b/tests/unit/live-store.test.mjs @@ -75,6 +75,13 @@ test("a regional live manifest resolves products against its declared root", asy assert.deepEqual(new LiveData(store).audio().sounds[10].layers[0].file, "sounds/default/audio/se1/a.flac"); }); +test("a live manifest loads its offered variant products", async () => { + const L = live(); + L.files.get("lives/7_expert.json").variants = ["noteskins/skin002.json"]; + const store = await loadLiveStore(`${ROOT}lives/7_expert.json`, { fetch: L.fetch }); + assert.ok(store.info.products["live.noteskin"].skin002); +}); + test("productDir: a product manifest path without .json", () => { assert.equal(productDir("scenes/default.json"), "scenes/default"); assert.equal(productDir("sounds/NoteSePatternId=1,3.json"), "sounds/NoteSePatternId=1,3"); diff --git a/tests/unit/read-set.test.mjs b/tests/unit/read-set.test.mjs index e34ecd4..99ba95a 100644 --- a/tests/unit/read-set.test.mjs +++ b/tests/unit/read-set.test.mjs @@ -113,17 +113,23 @@ test("plan: a planned material makes the shader library requests of its draw", ( // ---- command line const run = (...args) => spawnSync(process.execPath, [script, ...args], { encoding: "utf8" }); -test("read-set --features names the plan, serve and settings modes; option errors exit 2", () => { +test("read-set --features names the supported modes; option errors exit 2", () => { const f = run("--features"); assert.equal(f.status, 0); - assert.deepEqual(JSON.parse(f.stdout), { features: ["plan", "serve", "settings"] }); - assert.equal(f.stdout.trim(), '{"features":["plan","serve","settings"]}'); + assert.deepEqual(JSON.parse(f.stdout), { features: ["plan", "serve", "settings", "variants"] }); assert.equal(run().status, 2); const r = run("some/chart", "--plan", "--frames=10"); assert.equal(r.status, 2); assert.match(r.stderr, /--frames has no meaning with --plan/); }); +test("read-set validates variant manifest paths before opening the chart", () => { + for (const variants of ['{}', '["../outside.json"]', '["https://example.org/part.json"]']) { + const r = run("missing", "--variants=" + variants); + assert.equal(r.status, 2); assert.match(r.stderr, /relative product manifest paths/); + } +}); + test("read-set --serve answers each request line in order, one JSON line each, until the end of stdin", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ournotes-serve-")); try { diff --git a/tests/unit/story-products.test.mjs b/tests/unit/story-products.test.mjs index 20914ca..2d69621 100644 --- a/tests/unit/story-products.test.mjs +++ b/tests/unit/story-products.test.mjs @@ -55,3 +55,15 @@ test("an already fetched index is reused and cancellation is observed", async () controller.abort(new Error("cancelled")); await assert.rejects(loadStoryProducts(head.url, { head, lang: "ja", signal: controller.signal }), /cancelled/); }); + + +test("a story product can keep cue metadata with sound streams disabled", async () => { + const f = fixture(); + const manifest = JSON.parse(new TextDecoder().decode(f.files.get("stories/7.json"))); + manifest.audio = false; + f.files.set("stories/7.json", new TextEncoder().encode(JSON.stringify(manifest))); + const store = await loadStoryProducts(ROOT + "stories/7.json", { fetch: f.fetch, lang: "ja" }); + const data = new StoryData(store); + assert.equal(data.hasSounds, false); + assert.ok(data.parts.sounds.sheet); +}); diff --git a/tests/unit/story-schema-listing.test.mjs b/tests/unit/story-schema-listing.test.mjs index 6f2489c..1de84fa 100644 --- a/tests/unit/story-schema-listing.test.mjs +++ b/tests/unit/story-schema-listing.test.mjs @@ -76,3 +76,12 @@ test("first download, region manifests, query strings", () => { assert.equal(queryString({ story: 2, region: "tw", lang: null, kind: "" }), "?story=2®ion=tw"); assert.equal(queryString({ a: undefined }), ""); }); + + +test("playback language uses the entry's available resources", async () => { + const { playbackLanguage } = await import("../../examples/story-list/listing.js"); + const entry = { language: "ja", languages: ["ja", "ko"], titles: { en: "Title" } }; + assert.equal(playbackLanguage(entry, "ko"), "ko"); + assert.equal(playbackLanguage(entry, "en"), "ja"); + assert.equal(playbackLanguage(null, "en"), "en"); +}); diff --git a/tests/unit/story-schema.test.mjs b/tests/unit/story-schema.test.mjs index a101a7b..a7ec443 100644 --- a/tests/unit/story-schema.test.mjs +++ b/tests/unit/story-schema.test.mjs @@ -81,6 +81,16 @@ test("cue sheets and waveforms agree with episode sound rows", () => { fails(f => { f.cues.bgm_a.categories = "Bgm"; }, /categories must be an array/); }); +test("audio-disabled products retain valid cue metadata without waveform files", () => { + const silent = f => { + f.manifest.audio = false; + delete f.products.get(f.story.parts.sounds.sheet_a).files["a.flac"]; + }; + passes(silent); + fails(f => { silent(f); f.cues.bgm_a.categories = "Bgm"; }, /categories must be an array/); + fails(f => { f.manifest.audio = "false"; }, /audio/); +}); + test("fonts bind TMP component ids to the selected font and material", () => { fails(f => { delete f.fonts.ja.texts[f.textId]; }, /has no font binding/); fails(f => { f.fonts.ja.texts["missing:1"] = f.fonts.ja.texts[f.textId]; }, /not a TMP component/); diff --git a/tests/unit/validate-live.test.mjs b/tests/unit/validate-live.test.mjs index bb47a03..4ac1e99 100644 --- a/tests/unit/validate-live.test.mjs +++ b/tests/unit/validate-live.test.mjs @@ -203,3 +203,21 @@ test("two lives sharing one broken part report its errors once", () => { assert.equal(r.lines.filter((l) => l.includes("noteskins/skin001/noteskin.json: not valid JSON")).length, 1, r.lines.join("\n")); hasLine(r, "noteskins/skin001.json: not valid (reported above)"); }); + + +test("a product chart alias and its offered variant products validate", () => { + const build = missing => dir => { + const docs = productDocs(dir, chart()); + const live = docs["lives/7_expert.json"]; + delete docs["lives/7_expert.json"]; + live.variants = [missing ? "noteskins/missing.json" : "noteskins/skin001.json"]; + docs["charts/7_expert.json"] = live; + return docs; + }; + const valid = run(build(false)); + assert.equal(valid.status, 0, valid.lines.join("\n")); + assert.deepEqual(valid.lines, ["1/1 charts valid"]); + const invalid = run(build(true)); + assert.equal(invalid.status, 1); + hasLine(invalid, "noteskins/missing.json: missing"); +});