diff --git a/static/lib.mjs b/static/lib.mjs index d16ea49..af96fdf 100644 --- a/static/lib.mjs +++ b/static/lib.mjs @@ -271,6 +271,10 @@ export class GraphPlot extends HTMLElement { return name; } + getFilterLabels() { + return this.#filterLabels; + } + /** * @param {Object} labels */ diff --git a/tests/TAP.mjs b/tests/TAP.mjs new file mode 100644 index 0000000..db29659 --- /dev/null +++ b/tests/TAP.mjs @@ -0,0 +1,454 @@ +/** +* Tap - a 0 dependency TAP compliant test library useable from the commandline +* +* ```js +* import { Tap } from './src/TAP.mjs'; +* var t = new Tap; +* t.plan(3); +* +* t.ok(true, 'True is True'); # test will pass +* t.is(1, 2, 'one is two'); # test will fail +* +* var obj = {}; +* obj.method1 = function() { return true; }; +* +* t.can_ok(obj, 'method1'); # test will pass +* ``` +* +* @module TAP +* @license Artistic-2.0 +*/ + +/** @implements TapRenderer */ +class NodeRenderer { + /** @type {Array} */ + #thunks = []; + + out(text) { + this.#thunks.push( + // Because this is a ECMAScript module we have to do dynamic module loads + // of the node ecosystem when running in Node.js. + import('node:process').then(loaded => { + loaded.stdout.write(text + "\n"); + })); + } + + comment(lines) { + for (var line of lines) { + this.out('# ' + line); + } + } + + // This gives us a way to block on output. It's ghetto but async is a harsh task master. + async renderAll() { + for (var thunk of this.#thunks) { + await thunk; + } + + } +} + +/** @implements TapRenderer */ +class BrowserRenderer { + #target = document.body; + + /** @param {HtmlElement=} target */ + constructor(target) { + if (target) { + this.#target = target; + } + } + + /** @returns TextNode */ + #createText(text) { + return document.createTextNode(text); + } + + /** + * @param {Node} nodes + * @returns HTMLDivElement + */ + #createDiv(nodes) { + const div = document.createElement("div"); + div.append(...nodes); + return div; + } + + out(text) { + const textNode = this.#createText(text); + var div = this.#createDiv([textNode]); + if (text.startsWith("not ok")) { + div.setAttribute("class", "fail"); + } else if (text.startsWith("ok")) { + div.setAttribute("class", "pass"); + } + this.#target.appendChild(div); + } + + comment(lines) { + // TODO(jeremy): + var elems = []; + for (var line of lines) { + elems.push(this.#createText("# " + line), document.createElement("br")); + } + var commentDiv = this.#createDiv(elems); + commentDiv.setAttribute("class", "comment"); + this.#target.appendChild(commentDiv); + } +} + +/** + * The Tap Test Class helper. + */ +class Tap { + /** @type Number? */ + planned = null; + /** @type Number */ + counter = 0; + /** @type Number */ + passed = 0; + /** @type Number */ + failed = 0; + /** @type TapRenderer */ + renderer + + /** + * Construct a new Tap Suite with a renderLine function. + * @param {TapRenderer} + */ + constructor(renderer) { + this.renderer = renderer; + } + + /** + * @return {{"Renderer": BrowserRenderer, "Tap": Tap}} + */ + static Browser() { + var r = new BrowserRenderer(); + return {"Renderer": r, "Tap": new Tap(r)}; + return new Tap(new BrowserRenderer()); + } + + /** + * @return {{"Renderer": NodeRenderer, "Tap": Tap}} + */ + static Node() { + var r = new NodeRenderer(); + return {"Renderer": r, "Tap": new Tap(r)}; + } + + isPass() { + return this.passed != 0; + } + + /** Renders output for the test results */ + out(text) { + this.renderer.out(text); + }; + + /** + * Construct a Tap output message. + * + * @param {boolean} ok + * @param {string=} description + */ + mk_tap(ok, description) { + if (!this.planned) { + this.out("You tried to run tests without a plan. Gotta have a plan."); + throw new Error("You tried to run tests without a plan. Gotta have a plan."); + } + this.counter++; + this.out(ok + ' ' + this.counter + ' - ' + (description || "")); + }; + + + comment(msg) { + if (!msg) { + msg = " "; + } + var lines = msg.split("\n"); + this.renderer.comment(lines); + }; + + /** Render a pass TAP output message. + * @param {string} description + */ + pass(description) { + this.passed++; + this.mk_tap('ok', description); + }; + + /** Render a fail TAP output message. + * @param {string} description + */ + fail(description) { + this.failed++; + this.mk_tap('not ok', description); + }; + + /** Run a function as a TODO test. + * + * @param {function(this:Tap, boolean, description)} func + */ + todo(func) { + var self = this; + var tapper = self.mk_tap; + self.mk_tap = function(ok, desc) { + tapper.apply(self, [ok, "# TODO: " + desc]); + } + func(); + self.mk_tap = tapper; + } + + /** Run a function as a skip Test. + * + * @param {boolean} criteria + * @param {string} reason + * @param {number} count - The number of tests to skip + * @param {function(this:Tap, boolean, description)} func + */ + skip(criteria, reason, count, func) { + var self = this; + if (criteria) { + var tapper = self.mk_tap; + self.mk_tap = function(ok, desc) { + tapper.apply(self, [ok, desc]); + } + for (var i = 0; i < count; i++) { + self.fail("# SKIP " + reason) + } + self.mk_tap = tapper; + } else { + func(); + } + } + + /** Sets the test plan. + * Once set this can not be reset again. Any attempt to change the plan once already + * set will throw an exception. + * + * Call with no arguments if you don't want to specify the number of tests to run. + * + * @param {Number=} testCount + */ + plan(testCount) { + if (this.planned) { + throw new Error("you tried to set the plan twice!"); + } + if (!testCount) { + this.planned = 'no_plan'; + } else { + this.planned = testCount; + this.out('1..' + testCount); + } + }; + + #pass_if(func, desc) { + var result = func(); + if (result) { this.pass(desc) } + else { this.fail(desc) } + } + + // exception tests + + /** + * Tests that a function throws with a given error message. + * + * @param {function()} func + * @param {RegExp} msg + */ + throws_ok(func, msg) { + var errormsg = ' '; + if (typeof func != 'function') + this.comment('throws_ok needs a function to run'); + + try { + func(); + } + catch (err) { + errormsg = err + ''; + } + this.like(errormsg, msg, 'code threw [' + errormsg + '] expected: [' + msg + ']'); + } + + /** + * Tests that a function throws. + * + * @param {function()} func + */ + dies_ok(func) { + var errormsg = ' '; + var msg = false; + if (typeof func != 'function') + this.comment('throws_ok needs a function to run'); + + try { + func(); + } + catch (err) { + errormsg = err + ''; + msg = true; + } + this.ok(msg, 'code died with [' + errormsg + ']'); + } + + /** + * Tests that a function does not throw an exception. + * + * @param {function()} func + */ + lives_ok(func, msg) { + var errormsg = true; + if (typeof func != 'function') + this.comment('throws_ok needs a function to run'); + + try { + func(); + } + catch (err) { + errormsg = false; + } + this.ok(errormsg, msg); + } + + /** + * Tests that an object has a given method or function. + * + * @param {*} obj + */ + can_ok(obj) { + var desc = 'object can ['; + var pass = true; + for (var i = 1; i < arguments.length; i++) { + if (typeof (obj[arguments[i]]) != 'function') { + if (typeof (obj.prototype) != 'undefined') { + var result = typeof eval('obj.prototype.' + arguments[i]); + if (result == 'undefined') { + pass = false; + this.comment('Missing ' + arguments[i] + ' method'); + } + } else { + pass = false; + this.comment('Missing ' + arguments[i] + ' method'); + } + } + desc += ' ' + arguments[i]; + } + desc += ' ]'; + this.#pass_if(function() { + return pass; + }, desc); + + } + + /** + * Tests that two given objects are equal. + * + * @param {*} got + * @param {*} expected + * @param {string} desc + */ + is(got, expected, desc) { + this.#pass_if(function() { return got == expected; }, desc); + }; + + + /** + * Test that expression evaluates to true value + * + * @param {*} got + * @param {string} desc + */ + ok(got, desc) { + this.#pass_if(function() { return !!got; }, desc); + }; + + + /** + * Tests that a string matches the regex. + * + * @param {string} string + * @param {RegExp} regex + * @param {string} desc + */ + like(string, regex, desc) { + this.#pass_if(function() { + if (regex instanceof RegExp) { + return string.match(regex) + } else { + return string.indexOf(regex) != -1 + } + }, desc) + } + + /** + * The opposite of like. tests that the string doesn't match the regex. + * + * @param {string} string + * @param {RegExp} regex + * @param {string} desc + */ + unlike(string, regex, desc) { + this.#pass_if(function() { + return !string.match(regex); + }, desc) + } + +} + +/** + * Run a test and render a summarization. + * + * @param {Tap} t + * @param {string} testName + * @param {function(Tap)} test + */ +function runTest(t, testName, test) { + t.comment('running ' + testName + ' tests'); + try { + test(t); + summarize(t); + } + catch (err) { + t.comment("Test Suite Crashed!!! (" + err + ")"); + } + + return t; +} + +/** + * Output a summary of the tests as comments. + * + * @param {Tap} t + */ +function summarize(t) { + if (t.planned > t.counter) { + t.comment('looks like you planned ' + t.planned + ' tests but only ran ' + + t.counter + ' tests'); + } else if (t.planned < t.counter) { + t.comment('looks like you planned ' + t.planned + ' tests but ran ' + + (t.counter - t.planned) + ' tests extra'); + } + t.comment('ran ' + t.counter + ' tests out of ' + t.planned); + t.comment('passed ' + t.passed + ' tests out of ' + t.planned); + t.comment('failed ' + t.failed + ' tests out of ' + t.planned); +} + +/** + * @param {Tap} t + * @param {Array<{'plan': Number, name: string, 'test': function(Tap)}} suite + */ +function runSuite(t, suite) { + const plan = suite.reduce((acc, item) => { + return acc + item.plan + }, 0); + t.plan(plan); + for (var item of suite) { + t.comment('running ' + item.name + ' tests'); + item.test(t); + } + summarize(t); +} + +export { Tap, runTest, runSuite, summarize, BrowserRenderer, NodeRenderer }; + diff --git a/tests/Tap.d.js b/tests/Tap.d.js new file mode 100644 index 0000000..5a5e4a2 --- /dev/null +++ b/tests/Tap.d.js @@ -0,0 +1,20 @@ +/** @interface */ +class TapRenderer { + /** Renders output for the test results + * @param {string} text + */ + out(text) { + } + + /** Diagnostic formatter for TAP Output. + * + * @param {Array} lines + */ + comment(lines) { + } +} + +/** + * @typedef TapSuite + * @type {Array<{'plan': Number, name: string, 'test': function(Tap)}} + */ diff --git a/tests/TapHarness.html b/tests/TapHarness.html new file mode 100644 index 0000000..9be7ec6 --- /dev/null +++ b/tests/TapHarness.html @@ -0,0 +1,44 @@ + + + + + + + +Heracles custom elements test harness. + + + diff --git a/tests/suite.mjs b/tests/suite.mjs new file mode 100644 index 0000000..cef896a --- /dev/null +++ b/tests/suite.mjs @@ -0,0 +1,57 @@ +// TODO(jwall): Figure out how to handle the missing browser apis in node contexts. +import { GraphPlot, SpanSelector } from '../static/lib.mjs'; + +function deepEqual(got, expected) { + // Check if both are the same reference or both are null + if (got === expected) { return true; } + + // Check if both are objects (including arrays) and neither is null + if (typeof got !== 'object' || got === null || + typeof expected !== 'object' || expected === null) { + return false; + } + + // Get the keys of both objects + const keysGot = Object.keys(got), keysExpected = Object.keys(expected); + + // If number of properties is different, objects are not equivalent + if (keysGot.length !== keysExpected.length) { return false; } + + // Check all properties of a are in b and are equal + for (let key of keysGot) { + if (!keysExpected.includes(key) || !deepEqual(got[key], expected[key])) { return false; } + } + + // If we got this far, objects are considered equivalent + return true; +} + +/** + * @type {TapSuite} + */ +export var tapSuite = [ + { + plan: 2, + name: "Custom Element registration Tests", + test: function(t) { + t.ok(customElements.get(GraphPlot.elementName), `GraphPlot element is registered with name ${GraphPlot.elementName}`); + t.ok(customElements.get(SpanSelector.elementName), `SpanSelector element is registered with name ${SpanSelector.elementName}`); + } + }, + { + plan: 5, + name: "PopulateFilterData test", + test: function(t) { + const plot = new GraphPlot(); + t.ok(deepEqual(plot.getFilterLabels(), {}), "filter lables start out empty"); + plot.populateFilterData({}); + t.ok(typeof(deepEqual(plot.getFilterLabels()), {}), "filter lables are still empty"); + plot.populateFilterData({"foo": "bar"}); + t.ok(deepEqual(plot.getFilterLabels(), {"foo": ["bar"]}), "filter labels get set with list of one item on first label"); + plot.populateFilterData({"foo": "quux"}); + t.ok(deepEqual(plot.getFilterLabels(), {"foo": ["bar", "quux"]}), "list of two values after second label value"); + plot.populateFilterData({"foo": "bar"}); + t.ok(deepEqual(plot.getFilterLabels(), {"foo": ["bar", "quux"]}), "We don't double add the same value"); + } + } +];