refactor: replace array-based expressions with typed class hierarchy

Replace the old Lisp-like array expression representation
(["AND", ["PARAM", ...], ...]) with a class hierarchy rooted in
an abstract Expression class (Literal, Parameter, Binary, Unary,
FunctionCall, Conditional). This gives compile-time type safety
to expression construction, evaluation, and pattern matching.

Key changes:
- New lib/common/expression.ts defines the class hierarchy with
  map/evaluate/toString methods
- Rewrite parser as hand-rolled recursive descent, removing the
  parsimmon dependency
- Extend Path with a colon separator to unify parameter identity
  and attributes into a single address space (e.g.
  "Device.WiFi.SSID.1.Name:writable"). The colon field tracks
  how many trailing segments are attribute qualifiers. PathSet
  rewritten with separate param/attr segment indexes
- Flatten device attribute representation in the UI to
  colon-delimited keys mirroring the new path syntax
This commit is contained in:
Zaid Humaimidi committed 2026-03-12 14:58:20 -07:00
1 parent 75908e624f
commit 6f3ce6f7c8
71 files changed
+4045 -3449

No files matched your search

+6 -9
View File
@@ -2,13 +2,13 @@ import test from "node:test";
import assert from "node:assert";
import { EJSON } from "bson";
import { Filter } from "mongodb";
import { stringify, parse } from "../lib/common/expression/parser.ts";
import Expression from "../lib/common/expression.ts";
import { convertOldPrecondition } from "../lib/db/util.ts";
import { toMongoQuery } from "../lib/db/synth.ts";
void test("convertOldPrecondition", () => {
const tests = [
[{}, "true"],
[{}, "TRUE"],
[{ test: "test" }, 'test = "test"'],
[{ test: { $eq: "test" } }, 'test = "test"'],
[{ test: { $ne: "test" } }, 'test <> "test" OR test IS NULL'],
@@ -31,7 +31,7 @@ void test("convertOldPrecondition", () => {
[{ test: "test", test2: "test2" }, 'test = "test" AND test2 = "test2"'],
[
{ $or: [{ test: "test" }, { test: { $ne: "test" } }] },
'test = "test" OR (test <> "test" OR test IS NULL)',
'test = "test" OR test <> "test" OR test IS NULL',
],
[
{ test: { $gte: "test1", $ne: "test2" } },
@@ -54,7 +54,7 @@ void test("convertOldPrecondition", () => {
for (const t of tests) {
assert.strictEqual(
stringify(convertOldPrecondition(t[0] as Record<string, unknown>)),
convertOldPrecondition(t[0] as Record<string, unknown>).toString(),
t[1],
);
}
@@ -86,7 +86,6 @@ void test("toMongoQuery", async () => {
"Param1 <> 1657844103524",
{
"Param1._value": { $ne: 1657844103524 },
$and: [
{
"Param1._value": { $ne: { $date: "2022-07-15T00:15:03.524Z" } },
@@ -114,7 +113,6 @@ void test("toMongoQuery", async () => {
},
},
],
[
"LOWER(Param1) LIKE 'value'",
{
@@ -146,7 +144,7 @@ void test("toMongoQuery", async () => {
];
for (const [expStr, expect] of queries) {
const exp = parse(expStr);
const exp = Expression.parse(expStr);
let query = toMongoQuery(exp, "devices");
if (query) query = EJSON.serialize(query);
assert.deepStrictEqual(query, expect);
@@ -156,11 +154,10 @@ void test("toMongoQuery", async () => {
["Param1 = Param2", "Right-hand operand must be a literal value"],
["Param1 LIKE Param2", "Right-hand operand of 'LIKE' must be a string"],
["NOW() = 1", "Left-hand operand must be a parameter"],
["param{param2} = 1", "Left-hand operand must be a parameter"],
];
for (const [expStr, err] of failQueries) {
const exp = parse(expStr);
const exp = Expression.parse(expStr);
assert.throws(() => toMongoQuery(exp, "devices"), {
message: err,
});
+22 -19
View File
@@ -7,7 +7,7 @@ import VersionedMap from "../lib/versioned-map.ts";
import { Attributes } from "../lib/types.ts";
void test("getAliasDeclarations", () => {
const path = Path.parse("a.[aa:10,bb.[aaa:100].cc:1].b");
const path = Path.parse("a.[aa = 10 AND bb.[aaa = 100].cc = 1].b");
const decs = device.getAliasDeclarations(path, 99);
const expected = ["a.*.b", "a.*.aa", "a.*.bb.*.cc", "a.*.bb.*.aaa"];
@@ -32,66 +32,69 @@ void test("unpack", () => {
changes: new Set<string>(),
};
device.set(deviceData, Path.parse("a.1.b"), now, {
device.set(deviceData, "a.1.b", now, {
value: [now, ["b", "xsd:string"]],
});
device.set(deviceData, Path.parse("a.1.c"), now, {
device.set(deviceData, "a.1.c", now, {
value: [now, ["c", "xsd:string"]],
});
device.set(deviceData, Path.parse("a.1.a.1.a"), now, {
device.set(deviceData, "a.1.a.1.a", now, {
value: [now, ["", "xsd:string"]],
});
device.set(deviceData, Path.parse("a.1.a.1.b"), now, {
device.set(deviceData, "a.1.a.1.b", now, {
value: [now, ["b1", "xsd:string"]],
});
device.set(deviceData, Path.parse("a.1.a.1.c"), now, {
device.set(deviceData, "a.1.a.1.c", now, {
value: [now, ["c1", "xsd:string"]],
});
device.set(deviceData, Path.parse("a.1.a.2.a"), now, {
device.set(deviceData, "a.1.a.2.a", now, {
value: [now, ["", "xsd:string"]],
});
device.set(deviceData, Path.parse("a.1.a.2.b"), now, {
device.set(deviceData, "a.1.a.2.b", now, {
value: [now, ["b2", "xsd:string"]],
});
device.set(deviceData, Path.parse("a.1.a.2.c"), now, {
device.set(deviceData, "a.1.a.2.c", now, {
value: [now, ["c2", "xsd:string"]],
});
device.set(deviceData, Path.parse("a.2.b"), now, {
device.set(deviceData, "a.2.b", now, {
value: [now, ["b", "xsd:string"]],
});
device.set(deviceData, Path.parse("a.2.c"), now, {
device.set(deviceData, "a.2.c", now, {
value: [now, ["c", "xsd:string"]],
});
device.set(deviceData, Path.parse("a.2.a.1.a"), now, {
device.set(deviceData, "a.2.a.1.a", now, {
value: [now, ["", "xsd:string"]],
});
device.set(deviceData, Path.parse("a.2.a.1.b"), now, {
device.set(deviceData, "a.2.a.1.b", now, {
value: [now, ["b1", "xsd:string"]],
});
device.set(deviceData, Path.parse("a.2.a.1.c"), now, {
device.set(deviceData, "a.2.a.1.c", now, {
value: [now, ["c1", "xsd:string"]],
});
device.set(deviceData, Path.parse("a.2.a.2.a"), now, {
device.set(deviceData, "a.2.a.2.a", now, {
value: [now, ["", "xsd:string"]],
});
device.set(deviceData, Path.parse("a.2.a.2.b"), now, {
device.set(deviceData, "a.2.a.2.b", now, {
value: [now, ["c1", "xsd:string"]],
});
device.set(deviceData, Path.parse("a.2.a.2.c"), now, {
device.set(deviceData, "a.2.a.2.c", now, {
value: [now, ["b1", "xsd:string"]],
});
let unpacked: Path[];
unpacked = device.unpack(
deviceData,
Path.parse("a.[b:b,c:c].a.[b:b1,c:c1].a"),
Path.parse("a.[b='b' AND c='c'].a.[b='b1' AND c='c1'].a"),
);
assert.strictEqual(unpacked.length, 2);
assert.strictEqual(unpacked[0].toString(), "a.1.a.1.a");
assert.strictEqual(unpacked[1].toString(), "a.2.a.1.a");
unpacked = device.unpack(deviceData, Path.parse("a.*.a.[b:c1,c:b1].a"));
unpacked = device.unpack(
deviceData,
Path.parse("a.*.a.[b='c1' AND c='b1'].a"),
);
assert.strictEqual(unpacked.length, 1);
assert.strictEqual(unpacked[0].toString(), "a.2.a.2.a");
});
+10 -10
View File
@@ -1,13 +1,12 @@
import test from "node:test";
import assert from "node:assert";
import initSqlJs from "sql.js/dist/sql-asm.js";
import { parse, stringify } from "../lib/common/expression/parser.ts";
import {
bookmarkToExpression,
paginate,
toBookmark,
} from "../lib/common/expression/pagination.ts";
import { Expression } from "../lib/types.ts";
import Expression from "../lib/common/expression.ts";
import { covers, minimize } from "../lib/common/expression/synth.ts";
const VALUES = [null, -1, false, "a"];
@@ -79,15 +78,15 @@ async function testPaginate(
.map(([k, v]) => `${k} ${v > 0 ? "ASC" : "DESC"}`)
.join(", ");
const allMatches = await query(`${stringify(q2)} ORDER BY ${orderBy}`);
const allMatches = await query(`${q2.toString()} ORDER BY ${orderBy}`);
const [fulfilled, diff] = paginate(q1, q2, sort);
assert.ok(covers(q1, fulfilled));
const fulfilledMatches = await query(
`${stringify(fulfilled)} ORDER BY ${orderBy}`,
`${fulfilled.toString()} ORDER BY ${orderBy}`,
);
const diffMatches = await query(`${stringify(diff)} ORDER BY ${orderBy}`);
const diffMatches = await query(`${diff.toString()} ORDER BY ${orderBy}`);
assert.deepStrictEqual(allMatches, [...fulfilledMatches, ...diffMatches]);
if (allMatches.length === fulfilledMatches.length) return;
@@ -98,14 +97,14 @@ async function testPaginate(
);
const bookmark = toBookmark(sort, nextMatches[nextMatches.length - 1]);
const bookmarkFilter = bookmarkToExpression(bookmark, sort);
const capped = ["AND", q2, bookmarkFilter];
const capped = Expression.and(q2, bookmarkFilter);
assert.ok(!covers(q1, capped));
const cappedMatches = await query(
`(${stringify(capped)}) ORDER BY ${orderBy}`,
`(${capped.toString()}) ORDER BY ${orderBy}`,
);
assert.deepStrictEqual(cappedMatches, [...fulfilledMatches, ...nextMatches]);
const min = minimize(["OR", q1, capped]);
const min = minimize(Expression.or(q1, capped));
await testPaginate(min, q2, sort);
}
@@ -115,6 +114,7 @@ void test("paginate", async () => {
const sortOrders = getAllSortOrders(params);
for (const [q1, q2] of cases)
for (const sort of sortOrders)
await testPaginate(parse(q1), parse(q2), sort);
for (const sort of sortOrders) {
await testPaginate(Expression.parse(q1), Expression.parse(q2), sort);
}
});
+24 -28
View File
@@ -3,67 +3,63 @@ import assert from "node:assert";
import Path from "../lib/common/path.ts";
import PathSet from "../lib/common/path-set.ts";
void test("depth", () => {
const pathSet = new PathSet();
assert.strictEqual(pathSet.depth, 0);
pathSet.add(Path.parse(""));
assert.strictEqual(pathSet.depth, 1);
pathSet.add(Path.parse("a"));
assert.strictEqual(pathSet.depth, 2);
});
void test("add", () => {
const pathSet = new PathSet();
pathSet.add(Path.parse("a"));
pathSet.add(Path.parse("a"));
assert.strictEqual(pathSet.find(Path.parse("a"), true, true, 99).length, 1);
pathSet.add("a");
pathSet.add("a");
assert.strictEqual(
pathSet.findCompat(Path.parse("a"), true, true, 99).length,
1,
);
});
void test("get", () => {
const pathSet = new PathSet();
pathSet.add(Path.parse("a.*"));
pathSet.add(Path.parse("a.a"));
pathSet.add(Path.parse("*.*"));
pathSet.add("a.*");
pathSet.add("a.a");
pathSet.add("*.*");
assert.strictEqual(pathSet.get(Path.parse("a.*")).toString(), "a.*");
assert.strictEqual(pathSet.get(Path.parse("*.a")), null);
assert.strictEqual(pathSet.get("a.*").toString(), "a.*");
assert.equal(pathSet.get("*.a"), null);
});
void test("find", () => {
const pathSet = new PathSet();
pathSet.add(Path.parse("a"));
pathSet.add(Path.parse("a.*"));
pathSet.add(Path.parse("a.a"));
pathSet.add(Path.parse("*.a"));
pathSet.add(Path.parse("*.*"));
pathSet.add("a");
pathSet.add("a.*");
pathSet.add("a.a");
pathSet.add("*.a");
pathSet.add("*.*");
assert.deepStrictEqual(
pathSet.find(Path.parse(""), true, true, 1).map((p) => p.toString()),
pathSet.findCompat(Path.root, true, true, 1).map((p) => p.toString()),
["a"],
);
assert.deepStrictEqual(
pathSet.find(Path.parse(""), false, false, 2).map((p) => p.toString()),
pathSet.findCompat(Path.root, false, false, 2).map((p) => p.toString()),
["a", "a.*", "a.a", "*.a", "*.*"],
);
assert.deepStrictEqual(
pathSet.find(Path.parse("a.*"), false, false).map((p) => p.toString()),
pathSet
.findCompat(Path.parse("a.*"), false, false)
.map((p) => p.toString()),
["a.*"],
);
assert.deepStrictEqual(
pathSet.find(Path.parse("a.*"), false, true).map((p) => p.toString()),
pathSet.findCompat(Path.parse("a.*"), false, true).map((p) => p.toString()),
["a.*", "a.a"],
);
assert.deepStrictEqual(
pathSet.find(Path.parse("a.*"), true, false).map((p) => p.toString()),
pathSet.findCompat(Path.parse("a.*"), true, false).map((p) => p.toString()),
["a.*", "*.*"],
);
assert.deepStrictEqual(
pathSet.find(Path.parse("a.*"), true, true).map((p) => p.toString()),
pathSet.findCompat(Path.parse("a.*"), true, true).map((p) => p.toString()),
["a.*", "a.a", "*.a", "*.*"],
);
});
+138 -15
View File
@@ -8,38 +8,161 @@ void test("parse", () => {
assert.throws(() => Path.parse(".a"));
assert.throws(() => Path.parse("a."));
assert.throws(() => Path.parse("a..b"));
assert.throws(() => Path.parse("b*"));
assert.throws(() => Path.parse("*b"));
assert.doesNotThrow(() => Path.parse("b*"));
assert.doesNotThrow(() => Path.parse("*b"));
assert.throws(() => Path.parse("a.b c.d"));
assert.throws(() => Path.parse("a["));
assert.throws(() => Path.parse("a[b"));
assert.throws(() => Path.parse("a[b:"));
assert.throws(() => Path.parse('a[b:"waef]'));
assert.doesNotThrow(() => Path.parse("[]"));
assert.doesNotThrow(() => Path.parse("[a:]"));
assert.doesNotThrow(() => Path.parse("*"));
assert.doesNotThrow(() => Path.parse(""));
assert.throws(() => Path.parse(""));
});
void test("toString", () => {
const path1 = Path.parse('abc.[ abc : 123 , 123 : abc , 123: " abc "].123');
const path2 = Path.parse('abc.[123: " abc ",abc:123,123:abc].123');
const path1 = Path.parse('abc.[ abc=123 and def=" abc " ].123');
const path2 = Path.parse('abc.[abc = 123 AND def = " abc "].123');
assert.strictEqual(path1.toString(), path2.toString());
});
void test("slice", () => {
const path = Path.parse("a.*.b.[x:y].c");
const path = Path.parse(`a.*.b.[x = "y"].c`);
const sliced = path.slice(1, -1);
assert.strictEqual(sliced.toString(), '*.b.[x:"y"]');
assert.strictEqual(sliced.toString(), '*.b.[x = "y"]');
assert.strictEqual(sliced.alias, 0b100);
assert.strictEqual(sliced.wildcard, 0b1);
const path2 = Path.parse("a.b:c.d");
// Trim from right into colon region
assert.strictEqual(path2.slice(0, 3).toString(), "a.b:c");
assert.strictEqual(path2.slice(0, 3).colon, 1);
// Trim from right past colon region
assert.strictEqual(path2.slice(0, 2).toString(), "a.b");
assert.strictEqual(path2.slice(0, 2).colon, 0);
// Start exactly at colon boundary (all-colon result)
assert.strictEqual(path2.slice(2, 4).toString(), ":c.d");
assert.strictEqual(path2.slice(2, 4).colon, 2);
// Start past colon boundary (colon dropped)
assert.strictEqual(path2.slice(3, 4).toString(), "d");
assert.strictEqual(path2.slice(3, 4).colon, 0);
// Span across boundary from both sides
assert.strictEqual(path2.slice(1, 3).toString(), "b:c");
assert.strictEqual(path2.slice(1, 3).colon, 1);
});
void test("concat", () => {
const path1 = Path.parse("a");
const path2 = Path.parse("*.[a:b]");
const concat = path1.concat(path2);
assert.strictEqual(concat.toString(), 'a.*.[a:"b"]');
assert.strictEqual(concat.alias, 0b100);
assert.strictEqual(concat.wildcard, 0b10);
// Alias and wildcard propagation
const c0 = Path.parse("a").concat(Path.parse('*.[a = "b"]'));
assert.strictEqual(c0.toString(), 'a.*.[a = "b"]');
assert.strictEqual(c0.alias, 0b100);
assert.strictEqual(c0.wildcard, 0b10);
// Left plain, right all-colon
const allColon = Path.parse("a:b.c").slice(1, 3);
const c1 = Path.parse("a.b").concat(allColon);
assert.strictEqual(c1.toString(), "a.b:b.c");
assert.strictEqual(c1.colon, 2);
// Left colon, right all-colon
const c2 = Path.parse("a:b").concat(Path.parse("a.b:c.d").slice(2, 4));
assert.strictEqual(c2.toString(), "a:b.c.d");
assert.strictEqual(c2.colon, 3);
// Both have mixed colon — should throw
assert.throws(() => Path.parse("a:b").concat(Path.parse("c:d")));
});
void test("old alias format", () => {
// Empty brackets
const empty = Path.parse("a.[].b");
assert.strictEqual(empty.alias, 0b10);
assert.strictEqual(empty.toString(), "a.[TRUE].b");
// Single key-value
const single = Path.parse("a.[b:c].d");
assert.strictEqual(single.alias, 0b10);
assert.strictEqual(single.toString(), 'a.[b = "c"].d');
// Multiple key-value pairs
const multi = Path.parse("a.[b:1,c:2].d");
assert.strictEqual(multi.alias, 0b10);
assert.strictEqual(multi.toString(), 'a.[b = "1" AND c = "2"].d');
// Key with empty value
const emptyVal = Path.parse("a.[b:].d");
assert.strictEqual(emptyVal.toString(), 'a.[b = ""].d');
// Value containing colons (split on first : only)
const colonVal = Path.parse("a.[b:c:d].e");
assert.strictEqual(colonVal.toString(), 'a.[b = "c:d"].e');
// Value containing spaces (trimmed)
const spaceVal = Path.parse("a.[b:hello world].c");
assert.strictEqual(spaceVal.toString(), 'a.[b = "hello world"].c');
// Unquoted values are trimmed
const trimmed = Path.parse("a.[b: hello ].c");
assert.strictEqual(trimmed.toString(), 'a.[b = "hello"].c');
// Whitespace around keys is trimmed
const keySpace = Path.parse("a.[ b : c ].d");
assert.strictEqual(keySpace.toString(), 'a.[b = "c"].d');
// Equivalence with new format
const oldFmt = Path.parse("x.[a:1,b:2].y");
const newFmt = Path.parse('x.[a = "1" AND b = "2"].y');
assert.strictEqual(oldFmt.toString(), newFmt.toString());
// Nested old-format alias
const nested = Path.parse("a.[b.[x:1].c:2].d");
assert.strictEqual(nested.toString(), 'a.[b.[x = "1"].c = "2"].d');
// New SQL format still works
const sql = Path.parse("a.[b = 1 AND c = 2].d");
assert.strictEqual(sql.toString(), "a.[b = 1 AND c = 2].d");
// Double-quoted value containing closing bracket
const quotedBracket = Path.parse('a.[b:"hello]world"].c');
assert.strictEqual(quotedBracket.toString(), 'a.[b = "hello]world"].c');
// Double-quoted value containing comma
const quotedComma = Path.parse('a.[b:"hello,world"].c');
assert.strictEqual(quotedComma.toString(), 'a.[b = "hello,world"].c');
// Double-quoted value with escape sequences (JSON semantics)
const escaped = Path.parse('a.[b:"hello\\"world"].c');
assert.strictEqual(escaped.toString(), 'a.[b = "hello\\"world"].c');
// Single-quoted value
const singleQuoted = Path.parse("a.[b:'value'].c");
assert.strictEqual(singleQuoted.toString(), 'a.[b = "value"].c');
// Invalid old format (no colon) still throws
assert.throws(() => Path.parse("[abc]"));
});
void test("slice concat round-trip", () => {
const path = Path.parse("a.b:c.d");
// Round-trips at every split position up to and including the boundary
for (let i = 1; i <= path.paramLength; i++) {
const rejoined = path.slice(0, i).concat(path.slice(i));
assert.strictEqual(rejoined.toString(), path.toString());
assert.strictEqual(rejoined.colon, path.colon);
}
// Split inside the colon region: right half loses colon,
// but concat still extends the left's attr region
const left = path.slice(0, 3);
const right = path.slice(3);
assert.strictEqual(left.colon, 1);
assert.strictEqual(right.colon, 0);
const rejoined = left.concat(right);
assert.strictEqual(rejoined.toString(), "a.b:c.d");
assert.strictEqual(rejoined.colon, 2);
});
+34 -14
View File
@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert";
import initSqlJs from "sql.js/dist/sql-asm.js";
import { covers, minimize, unionDiff } from "../lib/common/expression/synth.ts";
import { parse, stringify } from "../lib/common/expression/parser.ts";
import Expression from "../lib/common/expression.ts";
const STRING_VALUES = [null, "", "a", "ab", "ab10", "ab-10"];
const DECIMAL_VALUES = [null, 0, -10, 10];
@@ -86,7 +86,7 @@ void test("minimize", async () => {
for (const c of cases) {
const res1 = await query(c);
const min = stringify(minimize(parse(c), true));
const min = minimize(Expression.parse(c), true).toString();
const res2 = await query(min);
assert.strictEqual(setsEqual(res1, res2), true);
}
@@ -104,9 +104,9 @@ void test("unionDiff", async () => {
for (const [c1, c2] of getPermutations(cases, cases)) {
const res1 = await query(c1);
const res2 = await query(c2);
const [union, diff] = unionDiff(parse(c1), parse(c2));
const res3 = await query(stringify(union));
const res4 = await query(stringify(diff));
const [union, diff] = unionDiff(Expression.parse(c1), Expression.parse(c2));
const res3 = await query(union.toString());
const res4 = await query(diff.toString());
const unionSet = new Set([...res1, ...res2]);
const diffSet = new Set(Array.from(res2).filter((r) => !res1.has(r)));
@@ -117,15 +117,35 @@ void test("unionDiff", async () => {
});
void test("covers", async () => {
assert.strictEqual(covers(false, false), true);
assert.strictEqual(covers(false, parse("decimal > 5 AND decimal < 3")), true);
assert.strictEqual(covers(parse("true"), parse("decimal > 0")), true);
assert.strictEqual(covers(parse("true"), parse("false")), true);
assert.strictEqual(covers(parse("false"), parse("false")), true);
assert.strictEqual(covers(parse("false"), parse("decimal > 0")), false);
assert.strictEqual(covers(parse("decimal >= 0"), parse("decimal > 0")), true);
assert.strictEqual(
covers(parse("decimal > 0"), parse("decimal >= 0")),
covers(Expression.parse("false"), Expression.parse("false")),
true,
);
assert.strictEqual(
covers(
Expression.parse("false"),
Expression.parse("decimal > 5 AND decimal < 3"),
),
true,
);
assert.strictEqual(
covers(Expression.parse("true"), Expression.parse("decimal > 0")),
true,
);
assert.strictEqual(
covers(Expression.parse("true"), Expression.parse("false")),
true,
);
assert.strictEqual(
covers(Expression.parse("false"), Expression.parse("decimal > 0")),
false,
);
assert.strictEqual(
covers(Expression.parse("decimal >= 0"), Expression.parse("decimal > 0")),
true,
);
assert.strictEqual(
covers(Expression.parse("decimal > 0"), Expression.parse("decimal >= 0")),
false,
);
@@ -139,7 +159,7 @@ void test("covers", async () => {
for (const [c1, c2] of cases) {
const res1 = await query(c1);
const res2 = await query(c2);
const coversResult = covers(parse(c1), parse(c2));
const coversResult = covers(Expression.parse(c1), Expression.parse(c2));
const actuallyCovers = Array.from(res2).every((r) => res1.has(r));
assert.strictEqual(