Compare commits
No commits in common. "7fd29ef9b4da37bcbf1c2e0ff0b781960aa1c674" and "94758243f380ed9c848d18d67b257a510563b126" have entirely different histories.
7fd29ef9b4
...
94758243f3
@ -42,11 +42,6 @@ export function parseAstLog(text) {
|
|||||||
stack.pop();
|
stack.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (depth > stack[stack.length - 1].depth + 1)
|
|
||||||
throw new Error(`AST log malformed: depth jump to ${depth} at: ${line}`);
|
|
||||||
if (depth > 0 && !slotFull.includes('.'))
|
|
||||||
throw new Error(`AST log malformed: missing slot at depth ${depth}: ${line}`);
|
|
||||||
|
|
||||||
stack[stack.length - 1].children.push(node);
|
stack[stack.length - 1].children.push(node);
|
||||||
stack.push(node);
|
stack.push(node);
|
||||||
}
|
}
|
||||||
@ -108,10 +103,6 @@ function parseRest(rest) {
|
|||||||
|
|
||||||
// ── Second pass: build typed model ──────────────────────────────────────────
|
// ── Second pass: build typed model ──────────────────────────────────────────
|
||||||
|
|
||||||
function collectUnknownProps(nodeProps, knownKeys) {
|
|
||||||
return Object.fromEntries(Object.entries(nodeProps).filter(([k]) => !knownKeys.has(k)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildModel(rawTree) {
|
export function buildModel(rawTree) {
|
||||||
const score = {
|
const score = {
|
||||||
type: 'score',
|
type: 'score',
|
||||||
@ -424,7 +415,7 @@ function buildVoice(node) {
|
|||||||
voice.articles.push(child.positionals[0]);
|
voice.articles.push(child.positionals[0]);
|
||||||
break;
|
break;
|
||||||
case 'voice.motif':
|
case 'voice.motif':
|
||||||
voice.motifs.push(buildMotif(child));
|
voice.motifs.push(child.props.label);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// ignore
|
// ignore
|
||||||
@ -439,61 +430,61 @@ function buildOffset(node) {
|
|||||||
type: 'offset',
|
type: 'offset',
|
||||||
tick: node.props.tick,
|
tick: node.props.tick,
|
||||||
stemNotes: [],
|
stemNotes: [],
|
||||||
unknownProps: collectUnknownProps(node.props, new Set(['tick'])),
|
clusters: [],
|
||||||
|
chains: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
if (child.parentSlot === 'offset' && child.slot === 'stem_note')
|
const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot;
|
||||||
offset.stemNotes.push(buildStemNote(child));
|
switch (fqSlot) {
|
||||||
|
case 'offset.stem_note':
|
||||||
|
offset.stemNotes.push({ pitch: child.props.pitch, effLength: child.props.eff_length });
|
||||||
|
break;
|
||||||
|
case 'offset.cluster':
|
||||||
|
offset.clusters.push(buildCluster(child));
|
||||||
|
break;
|
||||||
|
case 'offset.chain':
|
||||||
|
offset.chains.push({ index: child.positionals[0], children: child.children.map(buildGeneric) });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStemNote(node) {
|
function buildCluster(node) {
|
||||||
const KNOWN = new Set(['pitch', 'eff_length', 'adj_stress', 'adjacent']);
|
const cluster = {
|
||||||
return {
|
type: 'cluster',
|
||||||
type: 'stem_note',
|
index: node.positionals[0],
|
||||||
pitch: node.props.pitch,
|
|
||||||
effLength: node.props.eff_length ?? null,
|
|
||||||
adjacent: node.props.adjacent ?? null,
|
|
||||||
adjStress: node.props.adj_stress ?? null,
|
|
||||||
length: null,
|
|
||||||
weight: null,
|
|
||||||
chainText: node.children.find(c => c.slot === 'chain')?.props._tmp_string ?? '',
|
|
||||||
clauses: node.children
|
|
||||||
.filter(c => c.parentSlot === 'chain' && c.slot === 'clause')
|
|
||||||
.map(buildClause),
|
|
||||||
isDirty: false,
|
|
||||||
unknownProps: collectUnknownProps(node.props, KNOWN),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildClause(node) {
|
|
||||||
return {
|
|
||||||
type: 'clause',
|
|
||||||
index: node.positionals[0] ?? 0,
|
|
||||||
repeat: node.props.repeat ?? null,
|
repeat: node.props.repeat ?? null,
|
||||||
notes: node.children.filter(c => c.slot === 'note' && c.parentSlot === 'clause').map(c => ({ ...c.props })),
|
notes: [],
|
||||||
pauses: node.children.filter(c => c.slot === 'pause').map(c => ({ length: c.props.length })),
|
pauses: [],
|
||||||
stacks: node.children.filter(c => c.slot === 'stack').map(c => ({
|
groups: [],
|
||||||
length: c.props.length, netlength: c.props.netlength,
|
subchains: [],
|
||||||
notes: c.children.filter(cn => cn.slot === 'note').map(cn => ({ ...cn.props })),
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function buildMotif(node) {
|
|
||||||
const m = {
|
|
||||||
type: 'motif',
|
|
||||||
label: node.props.label,
|
|
||||||
stemNotes: [],
|
|
||||||
unknownProps: collectUnknownProps(node.props, new Set(['label'])),
|
|
||||||
};
|
|
||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
if (child.parentSlot === 'motif' && child.slot === 'stem_note')
|
switch (child.slot) {
|
||||||
m.stemNotes.push(buildStemNote(child));
|
case 'note':
|
||||||
|
cluster.notes.push({ ...child.props });
|
||||||
|
break;
|
||||||
|
case 'pause':
|
||||||
|
cluster.pauses.push({ length: child.props.length });
|
||||||
|
break;
|
||||||
|
case 'group':
|
||||||
|
cluster.groups.push({ length: child.props.length, netlength: child.props.netlength });
|
||||||
|
break;
|
||||||
|
case 'subchain':
|
||||||
|
cluster.subchains.push({ length: child.props.length, children: child.children.map(buildGeneric) });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
m.isStatic = m.stemNotes.length > 0 && !m.stemNotes.some(sn => Number.isInteger(sn.pitch));
|
|
||||||
return m;
|
return cluster;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGeneric(node) {
|
function buildGeneric(node) {
|
||||||
|
|||||||
@ -30,10 +30,6 @@ function shortView(node) {
|
|||||||
return { typeTag: 'voice', label: node.name, meta: [] };
|
return { typeTag: 'voice', label: node.name, meta: [] };
|
||||||
case 'offset':
|
case 'offset':
|
||||||
return { typeTag: 'tick', label: String(node.tick ?? '?'), meta: [] };
|
return { typeTag: 'tick', label: String(node.tick ?? '?'), meta: [] };
|
||||||
case 'motif':
|
|
||||||
return { typeTag: 'motif', label: node.label, meta: node.isStatic ? [{ key: 'static', value: '✓' }] : [] };
|
|
||||||
case 'stem_note':
|
|
||||||
return { typeTag: 'stem_note', label: String(node.pitch), meta: [] };
|
|
||||||
default:
|
default:
|
||||||
return { typeTag: node.type, label: node.type, meta: [] };
|
return { typeTag: node.type, label: node.type, meta: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,11 +7,6 @@ import { stressorToString } from '../exporter.js';
|
|||||||
|
|
||||||
const H4 = { style: 'margin:0 0 0.5rem' };
|
const H4 = { style: 'margin:0 0 0.5rem' };
|
||||||
|
|
||||||
function unknownPropFields(node) {
|
|
||||||
return Object.entries(node.unknownProps ?? {})
|
|
||||||
.map(([key, value]) => ({ key, value: String(value), editable: false }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStressor(str) {
|
function parseStressor(str) {
|
||||||
const groups = str.split(';').map(seg =>
|
const groups = str.split(';').map(seg =>
|
||||||
seg.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n))
|
seg.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n))
|
||||||
@ -179,68 +174,34 @@ export const PaneFO = {
|
|||||||
h(ObjectExtended, {
|
h(ObjectExtended, {
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'articles', value: node.articles.join(', ') || '—', editable: false },
|
{ key: 'articles', value: node.articles.join(', ') || '—', editable: false },
|
||||||
{ key: 'motifs', value: node.motifs.map(m => m.label).join(', ') || '—', editable: false },
|
{ key: 'motifs', value: node.motifs.join(', ') || '—', editable: false },
|
||||||
{ key: 'offsets', value: String(node.offsets.length), editable: false },
|
{ key: 'offsets', value: String(node.offsets.length), editable: false },
|
||||||
],
|
],
|
||||||
onChange: null,
|
onChange: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else if (node.type === 'offset') {
|
} else if (node.type === 'offset') {
|
||||||
const snLabel = sn => `${sn.pitch}${sn.effLength != null ? ' /' + sn.effLength : ''}${sn.clauses.length ? ' ×' + sn.clauses.length : ''}`;
|
const noteStr = n => `${n.pitch}${n.effLength != null ? ' ' + n.effLength : ''}`;
|
||||||
|
const clusterStr = c => c.notes.length
|
||||||
|
? c.notes.map(n => `${n.letter ?? ''}${n.shift != null ? n.shift : ''}${n.length != null ? ' ' + n.length : ''}`).join(', ')
|
||||||
|
: `cluster[${c.index}]`;
|
||||||
|
const noteItem = (text, i) => h('li', { class: 'se-object-item', key: i },
|
||||||
|
h('span', { class: 'se-object-label' }, text));
|
||||||
|
|
||||||
children.push(
|
children.push(
|
||||||
h('h4', H4, `Tick: ${node.tick}`),
|
h('h4', H4, `Tick: ${node.tick}`),
|
||||||
h(ObjectExtended, {
|
h(ObjectExtended, {
|
||||||
fields: [
|
fields: [{ key: 'tick', value: node.tick, editable: false }],
|
||||||
{ key: 'tick', value: node.tick, editable: false },
|
|
||||||
{ key: 'stem notes', value: node.stemNotes.map(snLabel).join(', ') || '—', editable: false },
|
|
||||||
...unknownPropFields(node),
|
|
||||||
],
|
|
||||||
onChange: null,
|
onChange: null,
|
||||||
}),
|
}),
|
||||||
);
|
node.stemNotes.length ? h('div', { style: 'margin-top:0.5rem' }, [
|
||||||
} else if (node.type === 'motif') {
|
h('strong', null, 'Stem notes'),
|
||||||
const pitchLabel = sn => (Number.isInteger(sn.pitch) ? `(ref${sn.pitch !== 0 ? sn.pitch : ''})` : String(sn.pitch))
|
h('ul', { class: 'se-object-list' }, node.stemNotes.map((n, i) => noteItem(noteStr(n), i))),
|
||||||
+ (sn.clauses.length ? ' ×' + sn.clauses.length : '');
|
]) : null,
|
||||||
children.push(
|
node.clusters.length ? h('div', { style: 'margin-top:0.5rem' }, [
|
||||||
h('h4', H4, `Motif: ${node.label}`),
|
h('strong', null, 'Clusters'),
|
||||||
h(ObjectExtended, {
|
h('ul', { class: 'se-object-list' }, node.clusters.map((c, i) => noteItem(clusterStr(c), i))),
|
||||||
fields: [
|
]) : null,
|
||||||
{ key: 'label', value: node.label, editable: false },
|
|
||||||
{ key: 'static', value: node.isStatic, editable: false, type: 'boolean' },
|
|
||||||
{ key: 'stem notes', value: node.stemNotes.map(pitchLabel).join(', ') || '—', editable: false },
|
|
||||||
...unknownPropFields(node),
|
|
||||||
],
|
|
||||||
onChange: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else if (node.type === 'stem_note') {
|
|
||||||
const bar = props.store.focusPath.find(n => n.type === 'bar') ?? null;
|
|
||||||
const markDirty = () => {
|
|
||||||
node.isDirty = true;
|
|
||||||
if (bar) bar.isDirty = true;
|
|
||||||
props.store.markDirty();
|
|
||||||
};
|
|
||||||
children.push(
|
|
||||||
h('h4', H4, `Stem note: ${node.pitch}`),
|
|
||||||
h(ObjectExtended, {
|
|
||||||
fields: [
|
|
||||||
{ key: 'pitch', value: node.pitch, editable: true },
|
|
||||||
{ key: 'length', value: node.length ?? '', editable: true },
|
|
||||||
{ key: 'weight', value: node.weight != null ? String(node.weight) : '', editable: true, type: 'number' },
|
|
||||||
{ key: 'adj_stress', value: node.adjStress != null ? String(node.adjStress) : '', editable: true, type: 'number' },
|
|
||||||
{ key: 'chain', value: node.chainText, editable: true },
|
|
||||||
{ key: 'clauses', value: String(node.clauses.length), editable: false },
|
|
||||||
...unknownPropFields(node),
|
|
||||||
],
|
|
||||||
onChange: ({ key, value }) => {
|
|
||||||
if (key === 'pitch') node.pitch = value;
|
|
||||||
if (key === 'length') node.length = value || null;
|
|
||||||
if (key === 'weight') node.weight = value === '' ? null : Number(value);
|
|
||||||
if (key === 'adj_stress') node.adjStress = value === '' ? null : Number(value);
|
|
||||||
if (key === 'chain') node.chainText = value;
|
|
||||||
markDirty();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
children.push(h('pre', { style: 'font-size:0.75rem;white-space:pre-wrap' },
|
children.push(h('pre', { style: 'font-size:0.75rem;white-space:pre-wrap' },
|
||||||
|
|||||||
@ -51,32 +51,12 @@ export const PaneSubObjects = {
|
|||||||
}
|
}
|
||||||
if (node.type === 'bar') {
|
if (node.type === 'bar') {
|
||||||
return Object.entries(node.voices).map(([name, v]) => ({
|
return Object.entries(node.voices).map(([name, v]) => ({
|
||||||
kind: 'voice', node: v, label: name,
|
kind: 'voice', node: v, label: name, hasChildren: v.offsets.length > 0,
|
||||||
hasChildren: v.offsets.length > 0 || v.motifs.some(m => m.isStatic),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (node.type === 'voice') {
|
if (node.type === 'voice') {
|
||||||
return [
|
return node.offsets.map((o, idx) => ({
|
||||||
...node.motifs
|
kind: 'offset', node: o, label: `tick ${o.tick ?? idx}`, hasChildren: false,
|
||||||
.filter(m => m.isStatic)
|
|
||||||
.map(m => ({ kind: 'motif', node: m, label: m.label, hasChildren: m.stemNotes.length > 0 })),
|
|
||||||
...node.offsets.map((o, idx) => ({
|
|
||||||
kind: 'offset', node: o, label: `tick ${o.tick ?? idx}`, hasChildren: o.stemNotes.length > 0,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (node.type === 'motif') {
|
|
||||||
return node.stemNotes.map(sn => ({
|
|
||||||
kind: 'stem_note', node: sn,
|
|
||||||
label: `pitch ${sn.pitch}${sn.clauses.length ? ` (${sn.clauses.length} clause${sn.clauses.length > 1 ? 's' : ''})` : ''}`,
|
|
||||||
hasChildren: false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (node.type === 'offset') {
|
|
||||||
return node.stemNotes.map(sn => ({
|
|
||||||
kind: 'stem_note', node: sn,
|
|
||||||
label: `pitch ${sn.pitch}${sn.clauses.length ? ` (${sn.clauses.length} clause${sn.clauses.length > 1 ? 's' : ''})` : ''}`,
|
|
||||||
hasChildren: false,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@ -429,101 +429,6 @@ ok('existing composer not duplicated', (noTitlePatched.match(/^composer:/mg) ??
|
|||||||
const nullInfoPatched = patchScore(META_SCORE, [], [], null);
|
const nullInfoPatched = patchScore(META_SCORE, [], [], null);
|
||||||
ok('null info leaves metadata unchanged', nullInfoPatched.includes('title: Old Title'));
|
ok('null info leaves metadata unchanged', nullInfoPatched.includes('title: Old Title'));
|
||||||
|
|
||||||
// ── Motif parsing ─────────────────────────────────────────────────────────
|
|
||||||
section('Motif parsing — dynamic motif');
|
|
||||||
const DYN_MOTIF = `00 bar '001P1L1M1'
|
|
||||||
01 bar.voice 'pi'
|
|
||||||
02 voice.motif label='oct'
|
|
||||||
03 motif.stem_note pitch=0
|
|
||||||
04 chain.clause 0 repeat=1
|
|
||||||
05 clause.note letter='o' shift=0 length=1 netlength=1
|
|
||||||
05 clause.note letter='o' shift=12 netlength=1 length=1
|
|
||||||
`;
|
|
||||||
const dynBar = buildModel(parseAstLog(DYN_MOTIF)).bars[0];
|
|
||||||
const dynVoice = dynBar.voices['pi'];
|
|
||||||
ok('motif is object', typeof dynVoice.motifs[0] === 'object');
|
|
||||||
ok('motif label', dynVoice.motifs[0].label === 'oct');
|
|
||||||
ok('motif pitch=0', dynVoice.motifs[0].stemNotes[0].pitch === 0);
|
|
||||||
ok('dynamic motif isStatic=false', dynVoice.motifs[0].isStatic === false);
|
|
||||||
ok('dynamic motif has 1 clause', dynVoice.motifs[0].stemNotes[0].clauses.length === 1);
|
|
||||||
ok('clause has 2 notes', dynVoice.motifs[0].stemNotes[0].clauses[0].notes.length === 2);
|
|
||||||
|
|
||||||
section('Motif parsing — static motif');
|
|
||||||
const STAT_MOTIF = `00 bar '001P1L1M1'
|
|
||||||
01 bar.voice 'pi'
|
|
||||||
02 voice.motif label='cadence'
|
|
||||||
03 motif.stem_note pitch='C4'
|
|
||||||
04 chain.clause 0
|
|
||||||
05 clause.note letter='o' shift=0 length=1 netlength=1
|
|
||||||
`;
|
|
||||||
const statVoice = buildModel(parseAstLog(STAT_MOTIF)).bars[0].voices['pi'];
|
|
||||||
ok('static motif isStatic=true', statVoice.motifs[0].isStatic === true);
|
|
||||||
ok('static motif pitch string', statVoice.motifs[0].stemNotes[0].pitch === 'C4');
|
|
||||||
|
|
||||||
section('Motif parsing — pause and stack in clause');
|
|
||||||
const PS_FIXTURE = `00 bar '001P1L1M1'
|
|
||||||
01 bar.voice 'pi'
|
|
||||||
02 voice.offset tick=0
|
|
||||||
03 offset.stem_note pitch='C4'
|
|
||||||
04 chain.clause 0
|
|
||||||
05 clause.note letter='o' shift=0 length=1 netlength=1
|
|
||||||
05 clause.pause length=1
|
|
||||||
04 chain.clause 1
|
|
||||||
05 clause.stack length=2 netlength=2
|
|
||||||
06 stack.note letter='o' shift=0
|
|
||||||
06 stack.note letter='e' shift=0
|
|
||||||
`;
|
|
||||||
const psVoice = buildModel(parseAstLog(PS_FIXTURE)).bars[0].voices['pi'];
|
|
||||||
const psSn = psVoice.offsets[0].stemNotes[0];
|
|
||||||
ok('two clauses', psSn.clauses.length === 2);
|
|
||||||
ok('first clause has 1 note + 1 pause', psSn.clauses[0].notes.length === 1 && psSn.clauses[0].pauses.length === 1);
|
|
||||||
ok('second clause has 1 stack', psSn.clauses[1].stacks.length === 1);
|
|
||||||
ok('stack has 2 notes', psSn.clauses[1].stacks[0].notes.length === 2);
|
|
||||||
|
|
||||||
section('Motif parsing — nested chain ignored gracefully');
|
|
||||||
const NC_FIXTURE = `00 bar '001P1L1M1'
|
|
||||||
01 bar.voice 'pi'
|
|
||||||
02 voice.offset tick=0
|
|
||||||
03 offset.stem_note pitch='C4'
|
|
||||||
04 chain.clause 0
|
|
||||||
05 clause.note letter='o' shift=0 length=1 netlength=1
|
|
||||||
05 clause.chain length=2
|
|
||||||
06 chain.clause 0
|
|
||||||
07 clause.stack length=1 netlength=1
|
|
||||||
08 stack.note letter='o' shift=0
|
|
||||||
`;
|
|
||||||
const ncSn = buildModel(parseAstLog(NC_FIXTURE)).bars[0].voices['pi'].offsets[0].stemNotes[0];
|
|
||||||
ok('nested chain: only 1 top-level clause', ncSn.clauses.length === 1);
|
|
||||||
ok('nested chain: only direct note counted', ncSn.clauses[0].notes.length === 1);
|
|
||||||
ok('nested chain: no stacks leak from nested chain', ncSn.clauses[0].stacks.length === 0);
|
|
||||||
|
|
||||||
section('Depth-jump guard (abort)');
|
|
||||||
const DJ_FIXTURE = `00 bar '001P1L1M1'
|
|
||||||
01 bar.voice 'pi'
|
|
||||||
02 voice.motif label='oct'
|
|
||||||
03 motif.stem_note pitch='C4'
|
|
||||||
06 chain.clause 0
|
|
||||||
`;
|
|
||||||
let djThrew = false;
|
|
||||||
try { parseAstLog(DJ_FIXTURE); } catch (e) { djThrew = true; }
|
|
||||||
ok('depth jump throws', djThrew);
|
|
||||||
|
|
||||||
section('No-slot guard (abort)');
|
|
||||||
const NS_FIXTURE = `00 bar '001P1L1M1'
|
|
||||||
01 voice
|
|
||||||
`;
|
|
||||||
let nsThrew = false;
|
|
||||||
try { parseAstLog(NS_FIXTURE); } catch (e) { nsThrew = true; }
|
|
||||||
ok('missing slot throws', nsThrew);
|
|
||||||
|
|
||||||
section('Full fixture integration');
|
|
||||||
ok('fixture parses without error (checked above)', raw && raw.slot === 'root');
|
|
||||||
ok('fixture has 432 bars', model.bars.length === 432);
|
|
||||||
const voicesWithMotifs = model.bars.flatMap(b => Object.values(b.voices)).filter(v => v.motifs.length > 0);
|
|
||||||
ok('fixture voices have motif objects', voicesWithMotifs.every(v => v.motifs.every(m => typeof m === 'object' && 'label' in m)));
|
|
||||||
const offsetsWithStemNotes = model.bars.flatMap(b => Object.values(b.voices)).flatMap(v => v.offsets).filter(o => o.stemNotes.length > 0);
|
|
||||||
ok('fixture offsets have stem note objects', offsetsWithStemNotes.every(o => o.stemNotes.every(sn => 'pitch' in sn && 'clauses' in sn)));
|
|
||||||
|
|
||||||
// ── Summary ────────────────────────────────────────────────────────────────
|
// ── Summary ────────────────────────────────────────────────────────────────
|
||||||
console.log(`\n══ ${pass} passed, ${fail} failed ══\n`);
|
console.log(`\n══ ${pass} passed, ${fail} failed ══\n`);
|
||||||
process.exit(fail > 0 ? 1 : 0);
|
process.exit(fail > 0 ? 1 : 0);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user