Initial Commit
This commit is contained in:
48
packages/foundation/data/pagination/tests/dep-graph.test.ts
Normal file
48
packages/foundation/data/pagination/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Verify that the runtime dependencies declared in package.json match those
|
||||
* actually imported in the source tree — no undeclared deps, no phantom deps.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_ROOT = join(import.meta.dirname, "..");
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(PKG_ROOT, "package.json"), "utf-8")
|
||||
) as {
|
||||
dependencies: Record<string, string>;
|
||||
devDependencies: Record<string, string>;
|
||||
};
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("has @suaveplan/error as a runtime dep", { timeout: 5000 }, () => {
|
||||
expect(pkg.dependencies["@suaveplan/error"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("has @suaveplan/types as a runtime dep", { timeout: 5000 }, () => {
|
||||
expect(pkg.dependencies["@suaveplan/types"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("has zod as a runtime dep", { timeout: 5000 }, () => {
|
||||
expect(pkg.dependencies["zod"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("has tslib as a runtime dep", { timeout: 5000 }, () => {
|
||||
expect(pkg.dependencies["tslib"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("has vitest as a devDep only", { timeout: 5000 }, () => {
|
||||
expect(pkg.devDependencies["vitest"]).toBeDefined();
|
||||
expect(pkg.dependencies["vitest"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("has fast-check as a devDep only", { timeout: 5000 }, () => {
|
||||
expect(pkg.devDependencies["fast-check"]).toBeDefined();
|
||||
expect(pkg.dependencies["fast-check"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("has mitata as a devDep only", { timeout: 5000 }, () => {
|
||||
expect(pkg.devDependencies["mitata"]).toBeDefined();
|
||||
expect(pkg.dependencies["mitata"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Enforce that production source files do not use disallowed globals
|
||||
* (Math.random, Date.now, crypto.randomUUID) that would make the library
|
||||
* non-deterministic or platform-dependent.
|
||||
*
|
||||
* The approved-files set is empty because pagination is pure computation
|
||||
* with no randomness or timestamp requirements.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC_ROOT = join(import.meta.dirname, "../src");
|
||||
|
||||
const DISALLOWED = [
|
||||
/\bMath\.random\b/,
|
||||
/\bDate\.now\b/,
|
||||
/\bcrypto\.randomUUID\b/,
|
||||
];
|
||||
|
||||
// No files are in the approved set — pagination has no randomness.
|
||||
const APPROVED_FILES: string[] = [];
|
||||
|
||||
function walkTs(dir: string): string[] {
|
||||
const result: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
result.push(...walkTs(full));
|
||||
} else if (entry.endsWith(".ts")) {
|
||||
result.push(full);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getSourceFiles(): string[] {
|
||||
return walkTs(SRC_ROOT).filter(
|
||||
(f) => !f.includes(".test.") && !f.includes(".bench.")
|
||||
);
|
||||
}
|
||||
|
||||
describe("no-disallowed globals", () => {
|
||||
const sourceFiles = getSourceFiles();
|
||||
|
||||
it("found at least one source file to check", { timeout: 5000 }, () => {
|
||||
expect(sourceFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const pattern of DISALLOWED) {
|
||||
it(`no source file uses ${pattern.source}`, { timeout: 5000 }, () => {
|
||||
const violations: string[] = [];
|
||||
for (const file of sourceFiles) {
|
||||
const relative = file.replace(`${SRC_ROOT}/`, "");
|
||||
if (APPROVED_FILES.some((ap) => file.endsWith(ap))) continue;
|
||||
|
||||
const content = readFileSync(file, "utf-8");
|
||||
if (pattern.test(content)) {
|
||||
violations.push(relative);
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
149
packages/foundation/data/pagination/tests/property.test.ts
Normal file
149
packages/foundation/data/pagination/tests/property.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Property-based tests using fast-check.
|
||||
*
|
||||
* Covers:
|
||||
* - decodeCursor(encodeCursor(s)) === s for all non-empty strings and numbers
|
||||
* - decodeKeysetCursor(encodeKeysetCursor(keys)) deep-equals keys
|
||||
* - decodePageToken(encodePageToken(payload)) deep-equals payload
|
||||
* - paginateArray always returns ≤ limit items
|
||||
* - offsetToPage(pageToOffset(p, l), l) === p for all positive p and l
|
||||
*/
|
||||
|
||||
import { describe, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { decodeCursor, encodeCursor } from "../src/cursor/cursor-pagination.js";
|
||||
import {
|
||||
decodeKeysetCursor,
|
||||
encodeKeysetCursor,
|
||||
} from "../src/keyset/keyset-pagination.js";
|
||||
import {
|
||||
offsetToPage,
|
||||
pageToOffset,
|
||||
paginateArray,
|
||||
} from "../src/offset/offset.js";
|
||||
import { decodePageToken, encodePageToken } from "../src/tokens/page-tokens.js";
|
||||
import type {
|
||||
KeysetSortKey,
|
||||
PageTokenPayload,
|
||||
} from "../src/types/pagination.types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor encode/decode round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("property: cursor encode/decode round-trip", () => {
|
||||
it("round-trips any non-empty string", { timeout: 10000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string({ minLength: 1 }), (s) => {
|
||||
return decodeCursor(encodeCursor(s)) === s;
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips any safe integer", { timeout: 10000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer(), (n) => {
|
||||
return decodeCursor(encodeCursor(n)) === n;
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyset cursor round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("property: keyset cursor encode/decode round-trip", () => {
|
||||
const keysetArb: fc.Arbitrary<KeysetSortKey[]> = fc.array(
|
||||
fc.record({
|
||||
field: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
direction: fc.constantFrom("asc" as const, "desc" as const),
|
||||
value: fc.oneof(
|
||||
fc.string({ minLength: 0, maxLength: 50 }),
|
||||
fc.integer()
|
||||
),
|
||||
}),
|
||||
{ minLength: 1, maxLength: 5 }
|
||||
);
|
||||
|
||||
it("round-trips arbitrary keyset keys", { timeout: 10000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(keysetArb, (keys) => {
|
||||
const decoded = decodeKeysetCursor(encodeKeysetCursor(keys));
|
||||
return JSON.stringify(decoded) === JSON.stringify(keys);
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page token round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("property: page token encode/decode round-trip", () => {
|
||||
const payloadArb: fc.Arbitrary<PageTokenPayload> = fc.record(
|
||||
{
|
||||
v: fc.oneof(fc.string({ minLength: 1 }), fc.integer()),
|
||||
s: fc.option(fc.string({ minLength: 1 }), { nil: undefined }),
|
||||
d: fc.option(fc.constantFrom("asc" as const, "desc" as const), {
|
||||
nil: undefined,
|
||||
}),
|
||||
f: fc.option(fc.string({ minLength: 1 }), { nil: undefined }),
|
||||
},
|
||||
{ withDeletedKeys: false }
|
||||
);
|
||||
|
||||
it("round-trips arbitrary page token payloads", { timeout: 10000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(payloadArb, (payload) => {
|
||||
const filtered: PageTokenPayload = { v: payload.v };
|
||||
if (payload.s !== undefined) filtered.s = payload.s;
|
||||
if (payload.d !== undefined) filtered.d = payload.d;
|
||||
if (payload.f !== undefined) filtered.f = payload.f;
|
||||
const decoded = decodePageToken(encodePageToken(filtered));
|
||||
return JSON.stringify(decoded) === JSON.stringify(filtered);
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// paginateArray: always returns ≤ limit items
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("property: paginateArray respects limit", () => {
|
||||
it("always returns ≤ limit items", { timeout: 10000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.integer(), { minLength: 0, maxLength: 100 }),
|
||||
fc.integer({ min: 1, max: 20 }),
|
||||
fc.integer({ min: 1, max: 10 }),
|
||||
(items, page, limit) => {
|
||||
const res = paginateArray(items, page, limit);
|
||||
return res.data.length <= limit;
|
||||
}
|
||||
),
|
||||
{ numRuns: 300 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// offsetToPage / pageToOffset round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("property: offsetToPage(pageToOffset(p, l), l) === p", () => {
|
||||
it("round-trips for positive page and limit", { timeout: 10000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 10000 }),
|
||||
fc.integer({ min: 1, max: 1000 }),
|
||||
(page, limit) => {
|
||||
return (
|
||||
offsetToPage(pageToOffset(page, limit), limit) === page
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 300 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
63
packages/foundation/data/pagination/tests/tree-shake.test.ts
Normal file
63
packages/foundation/data/pagination/tests/tree-shake.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Verify tree-shaking readiness:
|
||||
* - sideEffects: false is set in package.json
|
||||
* - At least 5 named subpath exports are declared
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_ROOT = join(import.meta.dirname, "..");
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(PKG_ROOT, "package.json"), "utf-8")
|
||||
) as {
|
||||
sideEffects: boolean | string[];
|
||||
exports: Record<string, unknown>;
|
||||
};
|
||||
|
||||
describe("tree-shake", () => {
|
||||
it("sideEffects is false", { timeout: 5000 }, () => {
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("has at least 5 subpath exports (excluding package.json)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const subpaths = Object.keys(pkg.exports).filter(
|
||||
(k) => k !== "./package.json"
|
||||
);
|
||||
expect(subpaths.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
it("root export '.' is declared", { timeout: 5000 }, () => {
|
||||
expect(pkg.exports["."]).toBeDefined();
|
||||
});
|
||||
|
||||
it("cursor subpath export is declared", { timeout: 5000 }, () => {
|
||||
expect(pkg.exports["./cursor"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("offset subpath export is declared", { timeout: 5000 }, () => {
|
||||
expect(pkg.exports["./offset"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("keyset subpath export is declared", { timeout: 5000 }, () => {
|
||||
expect(pkg.exports["./keyset"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("adapters subpath export is declared", { timeout: 5000 }, () => {
|
||||
expect(pkg.exports["./adapters"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("helpers subpath export is declared", { timeout: 5000 }, () => {
|
||||
expect(pkg.exports["./helpers"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("errors subpath export is declared", { timeout: 5000 }, () => {
|
||||
expect(pkg.exports["./errors"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("schemas subpath export is declared", { timeout: 5000 }, () => {
|
||||
expect(pkg.exports["./schemas"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user