UNPKG

harmonyc

Version:

Harmony Code - model-driven BDD for Vitest

515 lines (514 loc) 14.2 kB
import { Routers } from "./Router.js"; export class Feature { constructor(name) { this.name = name; this.root = new Section(); this.prelude = ''; } get tests() { return makeTests(this.root); } get testGroups() { return makeGroups(this.tests); } toCode(cg) { cg.feature(this); } } export class Node { at([startToken, endToken]) { while (startToken && startToken.kind === 'newline') { startToken = startToken.next; } if (startToken) { this.start = { line: startToken.pos.rowBegin, column: startToken.pos.columnBegin - 1, }; } if (startToken && endToken) { let t = startToken; while (t.next && t.next !== endToken) { t = t.next; } this.end = { line: t.pos.rowEnd, column: t.pos.columnEnd - 1, }; } return this; } atSameAs(other) { this.start = other.start; this.end = other.end; return this; } } export class Branch extends Node { constructor(children = []) { super(); this.isFork = false; this.isEnd = false; this.children = children; children.forEach((child) => (child.parent = this)); } setFork(isFork) { this.isFork = isFork; return this; } setFeature(feature) { for (const child of this.children) child.setFeature(feature); return this; } addChild(child, index = this.children.length) { this.children.splice(index, 0, child); child.parent = this; return child; } get isLeaf() { return this.children.length === 0; } get successors() { if (!this.isLeaf) return this.children.filter((c, i) => i === 0 || c.isFork); else { if (this.isEnd) return []; const next = this.nextNonForkAncestorSibling; if (next) return [next]; return []; } } get nextNonForkAncestorSibling() { if (!this.parent) return undefined; const { nextSibling } = this; if (nextSibling && !nextSibling.isFork) return nextSibling; return this.parent.nextNonForkAncestorSibling; } get nextSibling() { if (!this.parent) return undefined; return this.parent.children[this.siblingIndex + 1]; } get siblingIndex() { var _a, _b; return (_b = (_a = this.parent) === null || _a === void 0 ? void 0 : _a.children.indexOf(this)) !== null && _b !== void 0 ? _b : -1; } toString() { return this.children .map((c) => (c.isFork ? '+ ' : '- ') + c.toString()) .join('\n'); } replaceWith(newBranch) { if (!this.parent) throw new Error('cannot replace root'); this.parent.children.splice(this.siblingIndex, 1, newBranch); newBranch.parent = this.parent; this.parent = undefined; return this; } switch(_i) { return this; } } export class Step extends Branch { constructor(action, responses = [], children, isFork = false) { super(children); this.action = action; this.responses = responses; this.isFork = isFork; } get phrases() { return [this.action, ...this.responses]; } toCode(cg) { if (this.responses[0] instanceof ErrorResponse) { cg.errorStep(this.action, this.responses[0]); } else { cg.step(this.action, this.responses); } } setFeature(feature) { this.action.setFeature(feature); for (const response of this.responses) response.setFeature(feature); return super.setFeature(feature); } headToString() { return this.phrases.join(' '); } toString() { return this.headToString() + indent(super.toString()); } get isEmpty() { return this.phrases.every((phrase) => phrase.isEmpty); } switch(i) { return new Step(this.action.switch(i), this.responses.map((r) => r.switch(i))); } } export class State { constructor(text = '') { this.text = text; } } export class Label extends Node { constructor(text = '') { super(); this.text = text; } get isEmpty() { return this.text === ''; } } export class Section extends Branch { constructor(label, children, isFork = false) { super(children); this.label = label !== null && label !== void 0 ? label : new Label(); this.isFork = isFork; } toString() { if (this.label.text === '') return super.toString(); return this.label.text + ':' + indent(super.toString()); } get isEmpty() { return this.label.isEmpty; } } export class Part { toSingleLineString() { return this.toString(); } } export class DummyKeyword extends Part { constructor(text = '') { super(); this.text = text; } toString() { return this.text; } } export class Word extends Part { constructor(text = '') { super(); this.text = text; } toString() { return this.text; } } export class Repeater extends Part { constructor(choices) { super(); this.choices = choices; } toString() { return `{${this.choices.map((ps) => ps.join(' ')).join(' && ')}}`; } toSingleLineString() { return `{${this.choices .map((ps) => ps.map((p) => p.toSingleLineString()).join(' ')) .join(' && ')}}`; } } export class Switch extends Part { constructor(choices) { super(); this.choices = choices; } toString() { return `{ ${this.choices.join('; ')} }`; } toSingleLineString() { return `{ ${this.choices.map((c) => c.toSingleLineString()).join('; ')} }`; } } export class Arg extends Part { } export class StringLiteral extends Arg { constructor(text = '') { super(); this.text = text; } toString() { return JSON.stringify(this.text); } toSingleLineString() { return this.toString(); } toCode(cg) { return cg.stringLiteral(this.text, { withVariables: true }); } toDeclaration(cg, index) { return cg.stringParamDeclaration(index); } } export class Docstring extends StringLiteral { toCode(cg) { return cg.stringLiteral(this.text, { withVariables: false }); } toString() { return this.text .split('\n') .map((l) => '| ' + l) .join('\n'); } toSingleLineString() { return super.toString(); } } export class CodeLiteral extends Arg { constructor(src = '') { super(); this.src = src; } toString() { return '`' + this.src + '`'; } toCode(cg) { return cg.codeLiteral(this.src); } toDeclaration(cg, index) { return cg.variantParamDeclaration(index); } } export class Phrase extends Node { constructor(parts) { super(); this.parts = parts; } setFeature(feature) { this.feature = feature; return this; } get keyword() { return this.kind === 'action' ? 'When' : 'Then'; } get args() { return this.parts.filter((c) => c instanceof Arg); } get isEmpty() { return this.parts.length === 0; } toString() { const parts = this.parts.map((p) => p.toString()); const isMultiline = parts.map((p) => p.includes('\n')); return parts .map((p, i) => i === 0 ? p : isMultiline[i - 1] || isMultiline[i] ? '\n' + p : ' ' + p) .join(''); } toSingleLineString() { return this.parts.map((p) => p.toSingleLineString()).join(' '); } switch(i) { return new this.constructor(this.parts.map((p) => (p instanceof Switch ? p.choices[i] : p))).setFeature(this.feature); } } export class Action extends Phrase { constructor() { super(...arguments); this.kind = 'action'; } toCode(cg) { if (this.isEmpty) return; cg.phrase(this); } } export class Response extends Phrase { constructor(parts, saveToVariable) { super([...parts, ...(saveToVariable ? [saveToVariable] : [])]); this.saveToVariable = saveToVariable; this.kind = 'response'; } get isEmpty() { return this.parts.length === 0 && !this.saveToVariable; } toString() { return `=> ${super.toString()}`; } toSingleLineString() { return `=> ${super.toSingleLineString()}`; } toCode(cg) { if (this.isEmpty) return; cg.phrase(this); } } export class ErrorResponse extends Response { constructor(message) { super(message ? [new DummyKeyword('!!'), message] : [new DummyKeyword('!!')]); this.message = message; } toCode(cg) { cg.errorStep; } } export class SetVariable extends Action { constructor(variableName, value) { super([new DummyKeyword(`\${${variableName}}`), value]); this.variableName = variableName; this.value = value; } toCode(cg) { cg.setVariable(this); } } export class SaveToVariable extends Part { constructor(variableName) { super(); this.variableName = variableName; } toCode(cg) { cg.saveToVariable(this); } toString() { return `\${${this.variableName}}`; } get words() { return []; } } export class Precondition extends Branch { constructor(state = '') { super(); this.state = new State(); this.state.text = state; } get isEmpty() { return this.state.text === ''; } } export function makeTests(root) { const routers = new Routers(root); let tests = []; let ic = routers.getIncompleteCount(); let newIc; do { const newTest = new Test(routers.nextWalk()); newIc = routers.getIncompleteCount(); if (newIc < ic) tests.push(newTest); ic = newIc; } while (ic > 0); // sort by order of appearance of the last branch const branchIndex = new Map(); let i = 0; function walk(branch) { branchIndex.set(branch, i++); for (const child of branch.children) walk(child); } walk(root); tests = tests.filter((t) => t.steps.length > 0); tests.sort((a, b) => branchIndex.get(a.last) - branchIndex.get(b.last)); resolveSwitches(tests); tests.forEach((test, i) => (test.testNumber = `T${i + 1}`)); return tests; } function resolveSwitches(tests) { for (let i = 0; i < tests.length; ++i) { const test = tests[i]; const phrases = test.steps.flatMap((s) => s.phrases); const switches = phrases.flatMap((p) => p.parts.filter((p) => p instanceof Switch)); if (switches.length === 0) continue; const count = switches[0].choices.length; if (switches.some((s) => s.choices.length !== count)) { throw new Error(`all switches in a test case must have the same number of choices: ${test.name} has ${switches.map((s) => s.choices.length)} choices`); } const newTests = switches[0].choices.map((_, j) => test.switch(j)); tests.splice(i, 1, ...newTests); i += count - 1; } } export class Test { constructor(branches) { this.branches = branches; this.branches = this.branches.filter((b) => !b.isEmpty); this.labels = this.branches .filter((b) => b instanceof Section) .filter((s) => !s.isEmpty) .map((s) => s.label); } get steps() { return this.branches.filter((b) => b instanceof Step); } get last() { return this.steps.at(-1); } get lastStrain() { // Find the last branch that has no forks after it const lastForking = this.branches.length - 1 - this.branches .slice() .reverse() .findIndex((b) => b.successors.length > 1); if (lastForking === this.branches.length) return this.branches.at(0); return this.branches.at(lastForking + 1); } get name() { return `${[this.testNumber, ...this.labels.map((x) => x.text)].join(' - ')}`; } toCode(cg) { cg.test(this); } toString() { return `+ ${this.name}:\n${this.steps .map((s) => ` - ${s.headToString()}`) .join('\n')}`; } switch(j) { return new Test(this.branches.map((b) => b.switch(j))); } } export function makeGroups(tests) { if (tests.length === 0) return []; if (tests[0].labels.length === 0) return [tests[0], ...makeGroups(tests.slice(1))]; const label = tests[0].labels[0]; let count = tests.findIndex((t) => // using identity instead of text equality, which means identically named labels will not be grouped together t.labels[0] !== label); if (count === -1) count = tests.length; if (count === 1) return [tests[0], ...makeGroups(tests.slice(1))]; tests.slice(0, count).forEach((test) => test.labels.shift()); return [ new TestGroup(label, makeGroups(tests.slice(0, count))), ...makeGroups(tests.slice(count)), ]; } export class TestGroup { constructor(label, items) { this.label = label; this.items = items; } toString() { return `+ ${this.label.text}:` + indent(this.items.join('\n')); } toCode(cg) { cg.testGroup(this); } } function indent(s) { if (!s) return ''; return ('\n' + s .split('\n') .map((l) => ' ' + l) .join('\n')); }