harmonyc
Version:
Harmony Code - model-driven BDD for Vitest
515 lines (514 loc) • 14.2 kB
JavaScript
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'));
}