Skip to content

Commit

Permalink
runtime type checking, part 1
Browse files Browse the repository at this point in the history
  • Loading branch information
alexeyraspopov committed Apr 22, 2024
1 parent 9c6c924 commit 754b8b3
Show file tree
Hide file tree
Showing 14 changed files with 766 additions and 40 deletions.
7 changes: 4 additions & 3 deletions .github/workflows/testing.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ jobs:
node-version: 20
- run: npm install
- run: npm run build
- run: TARGET=modern npm run test
- run: TARGET=spec npm run test
- run: TARGET=legacy npm run test
- run: TARGET=modern npm run integration
- run: TARGET=spec npm run integration
- run: TARGET=legacy npm run integration
- run: npm run test
1 change: 0 additions & 1 deletion integration/.gitignore

This file was deleted.

4 changes: 2 additions & 2 deletions integration/Data.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { test } from "node:test";
import { test } from "vitest";
import { deepEqual, equal, throws } from "node:assert/strict";

import { Data } from "../build";
import { Data } from "dataclass";

class Entity extends Data {
someString: string = "default string";
Expand Down
285 changes: 285 additions & 0 deletions integration/integration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
import { test } from "node:test";
import { deepEqual, equal, throws } from "node:assert/strict";

import { Data } from "dataclass";

class Entity extends Data {
someString = "default string";
someNum = 0.134;
someBool = true;
someNullable = null;

get exclamation() {
return this.someString + "!";
}
}

test("should create an entity with default values", () => {
let entity = Entity.create();

matches(entity, {
someString: "default string",
someNum: 0.134,
someBool: true,
someNullable: null,
});
});

test("should override defaults with custom values", () => {
let entity = Entity.create({ someNullable: "1", someString: "hello" });

matches(entity, {
someString: "hello",
someNum: 0.134,
someBool: true,
someNullable: "1",
});
});

test("should satisfy composition law", () => {
let entity = Entity.create();
let left = entity.copy({ someNum: 13, someBool: false });
let right = entity.copy({ someNum: 13 }).copy({ someBool: false });

deepEqual(left, right);
equal(left.equals(right), true);
});

test("should support subclassing", () => {
class SubEntity extends Entity {
someNewThing = "default";
}

let entityA = SubEntity.create();
let entityB = SubEntity.create({ someString: "test", someNewThing: "blah" });

matches(entityA, {
someString: "default string",
someNum: 0.134,
someBool: true,
someNullable: null,
someNewThing: "default",
});

matches(entityB, {
someString: "test",
someNum: 0.134,
someBool: true,
someNullable: null,
someNewThing: "blah",
});
});

test("should support polymorphism", () => {
class Base extends Data {
format = "AAA";

transform(value) {
return this.format.replace(/A/g, value);
}
}

class Child extends Base {
transform(value) {
return "-" + this.format.replace(/A/g, value);
}
}

let baseEntity = Base.create({ format: "AAAAA" });
let childEntity = Child.create();

equal(baseEntity.transform("1"), "11111");
equal(childEntity.transform("1"), "-111");
});

test("should create new entity based on existent", () => {
let entity = Entity.create({ someBool: false });
let updated = entity.copy({ someNum: 14 });

matches(entity, {
someString: "default string",
someNum: 0.134,
someBool: false,
someNullable: null,
});

matches(updated, {
someString: "default string",
someNum: 14,
someBool: false,
someNullable: null,
});
});

test("should compare custom values for two entities of the same type", () => {
let entityA = Entity.create({ someBool: false, someNullable: null });
let equalE = Entity.create({ someBool: false, someNum: 0.134 });
let unequal = Entity.create({ someBool: false, someNullable: undefined });
let entityB = Entity.create({ someNullable: "1" });
let entityC = Entity.create({ someNullable: null });
let extended = entityB.copy({ someBool: true });
let updated = entityA.copy({ someNum: 14 });

equal(entityA.equals(updated), false);
equal(entityA.equals(equalE), true);
equal(unequal.equals(equalE), false);
equal(entityB.equals(extended), true);
equal(entityB.equals(entityA), false);
equal(entityB.equals(entityC), false);
});

class Embedded extends Data {
name = "name";
age = 1;
entity = Entity.create();
date = new Date();
obj = { foo: "bar" };
}

test("should be serializable with embedded dataclass", () => {
let dummyDate = new Date("1996-12-17T03:24:00");
let embedded = Embedded.create({ date: dummyDate });
let raw = {
name: "name",
age: 1,
entity: {
someString: "default string",
someNum: 0.134,
someBool: true,
someNullable: null,
},
date: dummyDate.toISOString(),
obj: {
foo: "bar",
},
};
equal(JSON.stringify(embedded), JSON.stringify(raw));
});

test("should compare dataclass with nested value objects", () => {
let embeddedA = Embedded.create({
date: new Date("1996-12-17T03:24:00"),
entity: Entity.create({ someBool: false }),
obj: null,
});
let embeddedB = Embedded.create({
date: new Date("1996-12-17T03:24:00"),
entity: Entity.create({ someBool: false }),
obj: null,
});
let embeddedC = Embedded.create({
date: new Date("1996-12-17T03:24:00"),
entity: Entity.create({ someBool: true }),
});
let embeddedD = Embedded.create({
date: new Date("2001-12-17T03:24:00"),
entity: Entity.create({ someBool: true }),
});
let embeddedE = Embedded.create({
date: new Date("2001-12-17T03:24:00"),
entity: null,
});
equal(embeddedA.equals(embeddedB), true);
equal(embeddedB.equals(embeddedC), false);
equal(embeddedC.equals(embeddedD), false);
equal(embeddedD.equals(embeddedE), false);
});

test("should satisfy symmetry law", () => {
let a = Entity.create({ someString: "1" });
let b = Entity.create({ someString: "1" });
let c = Entity.create({ someString: "2" });

equal(a.equals(b), true);
equal(b.equals(a), true);
equal(a.equals(c), false);
equal(c.equals(a), false);
});

test("should satisfy transitivity law", () => {
let a = Entity.create({ someString: "hello" });
let b = Entity.create({ someString: "hello" });
let c = Entity.create({ someString: "hello" });

equal(a.equals(b), true);
equal(b.equals(c), true);
equal(a.equals(c), true);
});

test("should support iterables", () => {
let entity = Entity.create({ someBool: false });

deepEqual(Object.entries(entity), [
["someString", "default string"],
["someNum", 0.134],
["someBool", false],
["someNullable", null],
]);

deepEqual(Object.keys(entity), ["someString", "someNum", "someBool", "someNullable"]);

deepEqual(Object.values(entity), ["default string", 0.134, false, null]);
});

test("should not allow assignment", () => {
let entity = Entity.create({ someBool: false });

equal(Object.isFrozen(entity), true);

throws(() => {
entity.someBool = true;
}, /Cannot assign/);

throws(() => {
// @ts-ignore intentional addition of inexistent property to assert runtime error
entity.somethingElse = null;
}, /Cannot add property/);
});

test("should prohibit new properties", () => {
let entity = Entity.create({ someBool: false });

equal(Object.isSealed(entity), true);

throws(() => {
// @ts-ignore intentional addition of inexistent property to assert runtime error
Entity.create({ thisShouldNotBeHere: 1 });
}, /object is not extensible/);

throws(() => {
// @ts-ignore intentional addition of inexistent property to assert runtime error
Entity.create().copy({ thisShouldNotBeHere: 1 });
}, /object is not extensible/);
});

test("should support predefined getters", () => {
let entity = Entity.create({ someString: "abcde" });

equal(entity.exclamation, "abcde!");
});

test("should disallow use of constructor", () => {
throws(() => {
new Entity();
}, /Use Entity.create/);
});

test("should allow dynamic defaults per instance", () => {
class Ent extends Data {
id = Math.random().toString(16).slice(2, 8);
}
let a1 = Ent.create();
let a2 = a1.copy();
let b = Ent.create();
equal(a1.equals(a2), true);
equal(b.equals(a1), false);
equal(b.equals(a2), false);
});

function matches(entity, object, message) {
deepEqual(plain(entity), object, message);
}

function plain(target) {
return Object.fromEntries(Object.entries(target));
}
56 changes: 56 additions & 0 deletions integration/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { test, expectTypeOf } from "vitest";
import { deepEqual, throws } from "node:assert/strict";

import { Data } from "dataclass";
import { runtime, data } from "dataclass/runtime";

test("throws", () => {
@runtime
class Entity extends Data {
name = data.string("", data.required);
things = data.union([data.string(), data.number()], 1);
}

// @ts-expect-error
throws(() => Entity.create(), /is required but value was not provided/);
// @ts-expect-error
throws(() => Entity.create({ name: 123 }), /expected to be of type/);
matches(Entity.create({ name: "Liza" }), { name: "Liza", things: 1 });
});

test("defaults", () => {
@runtime
class Entity extends Data {
prop = data.number(13);
}

// @ts-expect-error
throws(() => Entity.create({ prop: "boo" }), /expected to be of type/);
matches(Entity.create(), { prop: 13 });

let entity = Entity.create();
expectTypeOf(entity.prop).toBeNumber();
});

test("inherited", () => {
@runtime
class Base extends Data {
name = data.string();
// blab = data.instance<Promise<number>>(Promise);
}

class Entity extends Base {
age: number | null = null;
}

// @ts-expect-error
throws(() => Entity.create({}), /is required but value was not provided/);
});

function matches(entity: Data, object: object, message?: string) {
deepEqual(plain(entity), object, message);
}

function plain(target: object) {
return Object.fromEntries(Object.entries(target));
}
Loading

0 comments on commit 754b8b3

Please sign in to comment.