1 Commits
Author SHA1 Message Date
zemion f1c41e7079 Release Fixture Tools 0.2.0
Verify / verify (push) Canceled after 0s
2026-09-02 13:00:37 +02:00
22 changed files with 1007 additions and 126 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 0.2.0 - 2026-09-02
- Add reusable provider definitions, richer relational constraints and
deterministic cross-table generation with explicit violation evidence.
## 0.1.0 - 2026-09-01
- Add bounded JSON Schema, SQL DDL, XSD, CSV-heading, and fixture-recipe import
+12 -4
View File
@@ -3,18 +3,25 @@
Generate deterministic, privacy-safe synthetic test fixtures entirely in the
browser. Fixture Tools is a local-first module in the add·ideas Toolbox.
## v0.1.0 capabilities
## v0.2.0 capabilities
- Import a focused subset of JSON Schema, SQL `CREATE TABLE` DDL, XSD, CSV
headings, or the app's portable fixture-model JSON
- Review and edit normalized tables, fields, types, required/unique rules,
numeric and length ranges, enumerations, and foreign-key relationships
numeric and length ranges, enumerations, scalar foreign keys, composite
unique constraints, and composite foreign-key relationships
- Generate reproducible rows from a text seed with uniform, sequential,
normal, and constant distributions
- Deliberately exercise boundary values or inject invalid values, with a
separate violation ledger so negative-test fixtures cannot be mistaken for
valid data
- Preserve referential integrity and field uniqueness for valid generated rows
- Generate tables in a deterministic dependency-topological order, with an
explicit error for cyclic references
- Audit positive, boundary and intentional-negative exercise counts in a
constraint coverage matrix
- Select deterministic provider profiles (person, network, commerce), with a
typed extension point for additional local profiles
- Export JSON, spreadsheet-safe CSV, NDJSON, XML, SQL `INSERT` statements, or a
reusable fixture recipe
- Use only reserved example domains for generated email addresses
@@ -24,8 +31,9 @@ browser. Fixture Tools is a local-first module in the add·ideas Toolbox.
The importers normalize common declarative constructs; they are not complete
JSON Schema, SQL, or XML Schema validators. Remote references, schema code,
database connections, XML entities, XSD `choice`/identity constraints, SQL
dialects beyond common table/column clauses, and production-scale data loads are
out of scope in v0.1.0.
dialects beyond common table/column clauses, cyclic relational recipes, and
production-scale data loads are out of scope in v0.2.0. Coverage counts describe
generated exercises; they do not prove schema or application coverage.
Inputs are capped at 2 MiB. A recipe can contain at most 20 tables and 100
fields per table; generation is capped at 50,000 total rows and 8 MiB per
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source
The corresponding source for Fixture Tools 0.1.0 is available at:
The corresponding source for Fixture Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/fixture-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/fixture-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+4 -4
View File
@@ -1,12 +1,12 @@
# Third-party notices
Fixture Tools 0.1.0 directly depends on these runtime packages:
Fixture Tools 0.2.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT |
+19 -19
View File
@@ -1,22 +1,22 @@
{
"name": "fixture-tools",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fixture-tools",
"version": "0.1.0",
"version": "0.2.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
@@ -42,24 +42,25 @@
}
},
"node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
}
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0"
},
"node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0",
"version": "0.2.0",
"license": "GPL-3.0-or-later",
"engines": {
"node": ">=22"
}
},
"node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"peerDependencies": {
"react": ">=18 <20",
@@ -67,17 +68,16 @@
}
},
"node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"bin": {
"toolbox-check": "dist/cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@adobe/css-tools": {
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "fixture-tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Generate deterministic, privacy-safe synthetic test fixtures locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
@@ -39,14 +39,14 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{
name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
],
});
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 0.2.0 - 2026-09-02
- Add reusable provider definitions, richer relational constraints and
deterministic cross-table generation with explicit violation evidence.
## 0.1.0 - 2026-09-01
- Add bounded JSON Schema, SQL DDL, XSD, CSV-heading, and fixture-recipe import
+3 -3
View File
@@ -1,5 +1,5 @@
==============================================================================
@add-ideas/toolbox-contract@0.2.3
@add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
==============================================================================
@add-ideas/toolbox-helpers@0.1.0
@add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later
==============================================================================
--- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
==============================================================================
@add-ideas/toolbox-shell-react@0.2.3
@add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
+12 -4
View File
@@ -3,18 +3,25 @@
Generate deterministic, privacy-safe synthetic test fixtures entirely in the
browser. Fixture Tools is a local-first module in the add·ideas Toolbox.
## v0.1.0 capabilities
## v0.2.0 capabilities
- Import a focused subset of JSON Schema, SQL `CREATE TABLE` DDL, XSD, CSV
headings, or the app's portable fixture-model JSON
- Review and edit normalized tables, fields, types, required/unique rules,
numeric and length ranges, enumerations, and foreign-key relationships
numeric and length ranges, enumerations, scalar foreign keys, composite
unique constraints, and composite foreign-key relationships
- Generate reproducible rows from a text seed with uniform, sequential,
normal, and constant distributions
- Deliberately exercise boundary values or inject invalid values, with a
separate violation ledger so negative-test fixtures cannot be mistaken for
valid data
- Preserve referential integrity and field uniqueness for valid generated rows
- Generate tables in a deterministic dependency-topological order, with an
explicit error for cyclic references
- Audit positive, boundary and intentional-negative exercise counts in a
constraint coverage matrix
- Select deterministic provider profiles (person, network, commerce), with a
typed extension point for additional local profiles
- Export JSON, spreadsheet-safe CSV, NDJSON, XML, SQL `INSERT` statements, or a
reusable fixture recipe
- Use only reserved example domains for generated email addresses
@@ -24,8 +31,9 @@ browser. Fixture Tools is a local-first module in the add·ideas Toolbox.
The importers normalize common declarative constructs; they are not complete
JSON Schema, SQL, or XML Schema validators. Remote references, schema code,
database connections, XML entities, XSD `choice`/identity constraints, SQL
dialects beyond common table/column clauses, and production-scale data loads are
out of scope in v0.1.0.
dialects beyond common table/column clauses, cyclic relational recipes, and
production-scale data loads are out of scope in v0.2.0. Coverage counts describe
generated exercises; they do not prove schema or application coverage.
Inputs are capped at 2 MiB. A recipe can contain at most 20 tables and 100
fields per table; generation is capped at 50,000 total rows and 8 MiB per
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source
The corresponding source for Fixture Tools 0.1.0 is available at:
The corresponding source for Fixture Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/fixture-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/fixture-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+4 -4
View File
@@ -1,12 +1,12 @@
# Third-party notices
Fixture Tools 0.1.0 directly depends on these runtime packages:
Fixture Tools 0.2.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT |
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "fixture-tools-shell-";
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(
+17 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.fixture-tools",
"name": "Fixture Tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Generate deterministic synthetic test data locally.",
"entry": "./",
"icon": "./favicon.svg",
@@ -21,6 +21,22 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{ "mediaType": "application/schema+json", "extensions": [".json"] },
{ "mediaType": "application/sql", "extensions": [".sql"] },
{ "mediaType": "application/xml", "extensions": [".xsd", ".xml"] },
{ "mediaType": "text/csv", "extensions": [".csv"] }
],
"produces": [
{ "mediaType": "application/json", "extensions": [".json"] },
{ "mediaType": "application/x-ndjson", "extensions": [".ndjson"] },
{ "mediaType": "text/csv", "extensions": [".csv"] },
{ "mediaType": "application/xml", "extensions": [".xml"] },
{ "mediaType": "application/sql", "extensions": [".sql"] }
]
},
"capabilities": { "required": [], "optional": [] },
"privacy": {
"processing": "local",
"fileUploads": true,
+63 -2
View File
@@ -38,7 +38,7 @@ const initialDataset = generateFixtures(initialParsed.model, {
invalidPercent: 0,
});
type View = "model" | "data" | "violations";
type View = "model" | "data" | "coverage" | "violations";
const TYPES: FixtureType[] = [
"string",
"integer",
@@ -334,6 +334,7 @@ export function Workbench() {
const [rows, setRows] = useState(8);
const [boundaryPercent, setBoundaryPercent] = useState(10);
const [invalidPercent, setInvalidPercent] = useState(0);
const [providerProfile, setProviderProfile] = useState("auto");
const [selectedTable, setSelectedTable] = useState(model.tables[0]!.name);
const [outputFormat, setOutputFormat] = useState<OutputFormat>("json");
const [view, setView] = useState<View>("data");
@@ -381,6 +382,7 @@ export function Workbench() {
rows,
boundaryPercent,
invalidPercent,
providerProfile,
});
setModel(validated);
setDataset(next);
@@ -584,6 +586,18 @@ export function Workbench() {
}
/>
</label>
<label>
Deterministic provider profile
<select
value={providerProfile}
onChange={(event) => setProviderProfile(event.target.value)}
>
<option value="auto">Auto by field name</option>
<option value="person">People</option>
<option value="network">Network</option>
<option value="commerce">Commerce</option>
</select>
</label>
<button className="primary-button" type="button" onClick={generate}>
Generate fixtures
</button>
@@ -591,7 +605,7 @@ export function Workbench() {
</section>
<nav className="tabs" aria-label="Fixture workspaces">
{(["model", "data", "violations"] as View[]).map((name) => (
{(["model", "data", "coverage", "violations"] as View[]).map((name) => (
<button
type="button"
key={name}
@@ -818,6 +832,53 @@ export function Workbench() {
)}
</section>
) : null}
{view === "coverage" ? (
<section className="panel">
<div className="panel-heading">
<div>
<h2>Constraint coverage matrix</h2>
<p>
Generation order:{" "}
{(dataset.generationOrder ?? []).join(" → ") || "not recorded"}.
Counts distinguish valid, boundary and intentionally invalid
evidence.
</p>
</div>
<span className="count-pill">
{dataset.coverage?.length ?? 0} rules
</span>
</div>
<div className="table-scroll" tabIndex={0}>
<table>
<thead>
<tr>
<th>Table</th>
<th>Constraint</th>
<th>Support</th>
<th>Positive</th>
<th>Boundary</th>
<th>Negative</th>
</tr>
</thead>
<tbody>
{(dataset.coverage ?? []).map((entry, index) => (
<tr key={`${entry.table}-${entry.constraint}-${index}`}>
<td>{entry.table}</td>
<td>
<code>{entry.constraint}</code>
</td>
<td>{entry.support}</td>
<td>{entry.positiveCases}</td>
<td>{entry.boundaryCases}</td>
<td>{entry.negativeCases}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
) : null}
</main>
);
}
+542 -8
View File
@@ -34,11 +34,23 @@ export interface FixtureField {
values?: FixtureScalar[];
reference?: string;
constant?: FixtureScalar;
/** Optional deterministic provider id; custom providers may add their own ids. */
provider?: string;
}
export type FixtureConstraint =
| { kind: "unique"; name?: string; fields: string[] }
| {
kind: "foreignKey";
name?: string;
fields: string[];
references: { table: string; fields: string[] };
};
export interface FixtureTable {
name: string;
fields: FixtureField[];
constraints?: FixtureConstraint[];
}
export interface FixtureModel {
@@ -55,6 +67,7 @@ export interface GenerationOptions {
rows: number;
boundaryPercent: number;
invalidPercent: number;
providerProfile?: string;
}
export interface FixtureViolation {
@@ -69,6 +82,32 @@ export interface GeneratedDataset {
rowsPerTable: number;
tables: Record<string, Array<Record<string, FixtureScalar>>>;
violations: FixtureViolation[];
generationOrder?: string[];
coverage?: ConstraintCoverageRow[];
}
export interface ProviderContext {
table: string;
field: FixtureField;
row: number;
integer(minimum: number, maximum: number): number;
pick<T>(values: readonly T[]): T;
}
export interface FixtureProviderProfile {
id: string;
label: string;
supports(field: FixtureField): boolean;
generate(context: ProviderContext): FixtureScalar | undefined;
}
export interface ConstraintCoverageRow {
table: string;
constraint: string;
support: "generated" | "validated" | "generated-and-validated";
positiveCases: number;
boundaryCases: number;
negativeCases: number;
}
const MAX_SOURCE = 2 * 1024 * 1024;
@@ -229,7 +268,16 @@ export function validateModel(model: FixtureModel): FixtureModel {
);
if (field.constant !== undefined)
validateConstant(field, `${table.name}.${field.name}`);
if (
field.provider !== undefined &&
!/^[A-Za-z][A-Za-z0-9_.-]{0,79}$/u.test(field.provider)
)
throw new TypeError(
`${table.name}.${field.name} has an invalid provider id.`,
);
}
if ((table.constraints?.length ?? 0) > 100)
throw new RangeError(`${table.name} exceeds 100 table constraints.`);
}
for (const table of model.tables)
for (const field of table.fields)
@@ -248,6 +296,47 @@ export function validateModel(model: FixtureModel): FixtureModel {
`${table.name}.${field.name} cannot reference another foreign-key field.`,
);
}
for (const table of model.tables) {
const localFields = new Set(table.fields.map((field) => field.name));
for (const [constraintIndex, constraint] of (
table.constraints ?? []
).entries()) {
const label = `${table.name} constraint ${constraint.name ?? constraintIndex + 1}`;
if (constraint.kind !== "unique" && constraint.kind !== "foreignKey")
throw new TypeError(`${label} has an unsupported kind.`);
if (
!Array.isArray(constraint.fields) ||
constraint.fields.length < 1 ||
constraint.fields.length > 16 ||
new Set(constraint.fields).size !== constraint.fields.length ||
constraint.fields.some((field) => !localFields.has(field))
)
throw new TypeError(
`${label} needs 116 distinct existing local fields.`,
);
if (constraint.name !== undefined && !NAME_PATTERN.test(constraint.name))
throw new TypeError(`${label} has an invalid name.`);
if (constraint.kind === "foreignKey") {
const target = model.tables.find(
(candidate) => candidate.name === constraint.references?.table,
);
const referenced = constraint.references?.fields;
if (
!target ||
!Array.isArray(referenced) ||
referenced.length !== constraint.fields.length ||
new Set(referenced).size !== referenced.length ||
referenced.some(
(field) =>
!target.fields.some((candidate) => candidate.name === field),
)
)
throw new TypeError(
`${label} has invalid composite reference columns.`,
);
}
}
}
return model;
}
@@ -327,6 +416,8 @@ function fromJsonSchema(source: string): ParsedModel {
distribution === "constant"
)
field.distribution = distribution;
if (typeof property["x-fixture-provider"] === "string")
field.provider = property["x-fixture-provider"];
if (property.pattern !== undefined)
notices.push(
`${rawName}: regular-expression generation is not inferred; edit the field model if needed.`,
@@ -367,9 +458,7 @@ function fromFixtureModel(source: string): ParsedModel {
!Array.isArray(table.fields)
)
throw new TypeError(`Fixture table ${tableIndex + 1} is invalid.`);
return {
name: table.name,
fields: table.fields.map((rawField, fieldIndex) => {
const fields = table.fields.map((rawField, fieldIndex) => {
const field = object(rawField);
if (
!field ||
@@ -433,8 +522,59 @@ function fromFixtureModel(source: string): ParsedModel {
? { reference: field.reference }
: {}),
...(isScalar(field.constant) ? { constant: field.constant } : {}),
...(typeof field.provider === "string"
? { provider: field.provider }
: {}),
};
}),
});
const constraints = Array.isArray(table.constraints)
? table.constraints.map((rawConstraint, index): FixtureConstraint => {
const constraint = object(rawConstraint);
if (
!constraint ||
(constraint.kind !== "unique" &&
constraint.kind !== "foreignKey") ||
!Array.isArray(constraint.fields) ||
!constraint.fields.every((field) => typeof field === "string")
)
throw new TypeError(
`Fixture constraint ${table.name}.${index + 1} is invalid.`,
);
if (constraint.kind === "unique")
return {
kind: "unique",
fields: constraint.fields as string[],
...(typeof constraint.name === "string"
? { name: constraint.name }
: {}),
};
const reference = object(constraint.references);
if (
!reference ||
typeof reference.table !== "string" ||
!Array.isArray(reference.fields) ||
!reference.fields.every((field) => typeof field === "string")
)
throw new TypeError(
`Fixture composite reference ${table.name}.${index + 1} is invalid.`,
);
return {
kind: "foreignKey",
fields: constraint.fields as string[],
...(typeof constraint.name === "string"
? { name: constraint.name }
: {}),
references: {
table: reference.table,
fields: reference.fields as string[],
},
};
})
: [];
return {
name: table.name,
fields,
...(constraints.length ? { constraints } : {}),
};
});
return { model: validateModel({ tables }), notices: [] };
@@ -558,6 +698,21 @@ function sqlType(source: string): FixtureType {
return "string";
}
function sqlIdentifierList(source: string): string[] {
return splitSqlList(source).map((value, index) => {
const match =
/^(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|([A-Za-z_][A-Za-z0-9_$-]*))$/u.exec(
value.trim(),
);
if (!match)
throw new SyntaxError(`Unsupported SQL constraint column ${index + 1}.`);
return importedName(
match[1] ?? match[2] ?? match[3] ?? match[4] ?? `field_${index + 1}`,
`field_${index + 1}`,
);
});
}
function fromSql(source: string): ParsedModel {
const text = assertSource(source, "SQL DDL")
.replace(/--[^\n]*/gu, "")
@@ -590,10 +745,61 @@ function fromSql(source: string): ParsedModel {
expression.lastIndex = cursor;
const used = new Set<string>();
const fields: FixtureField[] = [];
const constraints: FixtureConstraint[] = [];
for (const definition of splitSqlList(body)) {
const named =
/^CONSTRAINT\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|([A-Za-z_][A-Za-z0-9_$-]*))\s+([\s\S]+)$/iu.exec(
definition,
);
const constraintName = named
? importedName(
named[1] ?? named[2] ?? named[3] ?? named[4] ?? "constraint",
"constraint",
)
: undefined;
const constraintSource = named?.[5] ?? definition;
const unique = /^(PRIMARY\s+KEY|UNIQUE)\s*\(([^)]+)\)/iu.exec(
constraintSource,
);
if (unique) {
const constraintFields = sqlIdentifierList(unique[2]!);
constraints.push({
kind: "unique",
...(constraintName ? { name: constraintName } : {}),
fields: constraintFields,
});
if (/^PRIMARY/iu.test(unique[1]!))
fields.forEach((field) => {
if (constraintFields.includes(field.name)) field.required = true;
});
continue;
}
const compositeReference =
/^FOREIGN\s+KEY\s*\(([^)]+)\)\s+REFERENCES\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|([A-Za-z_][A-Za-z0-9_$-]*))\s*\(([^)]+)\)/iu.exec(
constraintSource,
);
if (compositeReference) {
constraints.push({
kind: "foreignKey",
...(constraintName ? { name: constraintName } : {}),
fields: sqlIdentifierList(compositeReference[1]!),
references: {
table: importedName(
compositeReference[2] ??
compositeReference[3] ??
compositeReference[4] ??
compositeReference[5] ??
"table",
"table",
),
fields: sqlIdentifierList(compositeReference[6]!),
},
});
continue;
}
if (/^(?:CONSTRAINT|PRIMARY|UNIQUE|FOREIGN|CHECK)\b/iu.test(definition)) {
notices.push(
`${rawName}: table-level constraint retained as a review notice, not inferred.`,
`${rawName}: unsupported table-level constraint retained as a review notice.`,
);
continue;
}
@@ -644,6 +850,7 @@ function fromSql(source: string): ParsedModel {
tables.push({
name: importedName(rawName, `table_${tables.length + 1}`),
fields,
...(constraints.length ? { constraints } : {}),
});
}
if (!tables.length)
@@ -770,7 +977,7 @@ function fromXsd(source: string): ParsedModel {
});
const root = elements.find((element) => !fieldElements.includes(element));
const notices = [
"XSD v0.1 imports named sequence/all elements and basic built-in scalar types; facets, choices, keys and external schemas require review.",
"XSD v0.2 imports named sequence/all elements and basic built-in scalar types; facets, choices, keys and external schemas require review.",
];
return {
model: validateModel({
@@ -821,6 +1028,163 @@ class SeededRandom {
}
}
const FIRST_NAMES = ["Ada", "Grace", "Linus", "Katherine", "Alan", "Edsger"];
const LAST_NAMES = [
"Lovelace",
"Hopper",
"Torvalds",
"Johnson",
"Turing",
"Dijkstra",
];
const PRODUCTS = ["Atlas", "Beacon", "Compass", "Delta", "Ember", "Foundry"];
export const BUILTIN_PROVIDER_PROFILES: readonly FixtureProviderProfile[] = [
{
id: "person",
label: "People",
supports: (field) =>
field.type === "string" &&
/(?:first|last|display|full)_?name|company/iu.test(field.name),
generate({ field, row, pick }) {
if (/first/iu.test(field.name)) return pick(FIRST_NAMES);
if (/last/iu.test(field.name)) return pick(LAST_NAMES);
if (/company/iu.test(field.name)) return `${pick(LAST_NAMES)} Labs`;
return `${FIRST_NAMES[row % FIRST_NAMES.length]} ${pick(LAST_NAMES)}`;
},
},
{
id: "network",
label: "Network",
supports: (field) =>
field.type === "string" &&
/(?:ip|ipv4|host|domain|url)/iu.test(field.name),
generate({ field, row, integer }) {
if (/url/iu.test(field.name))
return `https://host-${row + 1}.example.test/path`;
if (/host|domain/iu.test(field.name))
return `host-${row + 1}.example.test`;
return `192.0.2.${integer(1, 254)}`;
},
},
{
id: "commerce",
label: "Commerce",
supports: (field) =>
field.type === "string" && /(?:sku|product|currency)/iu.test(field.name),
generate({ field, row, pick }) {
if (/currency/iu.test(field.name)) return pick(["EUR", "USD", "GBP"]);
if (/sku/iu.test(field.name))
return `SKU-${String(row + 1).padStart(6, "0")}`;
return `${pick(PRODUCTS)} ${row + 1}`;
},
},
];
/** Returns a stable target-before-dependent table order or rejects cycles. */
export function topologicalTableOrder(input: FixtureModel): string[] {
const model = validateModel(structuredClone(input));
const dependencies = new Map<string, Set<string>>(
model.tables.map((table) => [table.name, new Set<string>()]),
);
for (const table of model.tables) {
for (const field of table.fields) {
const target = field.reference?.split(".")[0];
if (target && target !== table.name)
dependencies.get(table.name)!.add(target);
}
for (const constraint of table.constraints ?? [])
if (
constraint.kind === "foreignKey" &&
constraint.references.table !== table.name
)
dependencies.get(table.name)!.add(constraint.references.table);
}
const result: string[] = [];
const pending = new Set(model.tables.map((table) => table.name));
while (pending.size) {
const next = model.tables.find(
(table) =>
pending.has(table.name) &&
[...dependencies.get(table.name)!].every((dependency) =>
result.includes(dependency),
),
);
if (!next)
throw new TypeError(
`Cyclic table reference dependency: ${[...pending].join(" → ")}.`,
);
result.push(next.name);
pending.delete(next.name);
}
return result;
}
function activeProviders(
profile: string | undefined,
providers: readonly FixtureProviderProfile[],
): readonly FixtureProviderProfile[] {
const ids = new Set<string>();
for (const provider of providers) {
if (
!/^[A-Za-z][A-Za-z0-9_.-]{0,79}$/u.test(provider.id) ||
ids.has(provider.id)
)
throw new TypeError(
`Invalid or duplicate fixture provider id ${provider.id}.`,
);
ids.add(provider.id);
}
if (!profile || profile === "auto") return providers;
const selected = providers.find((provider) => provider.id === profile);
if (!selected)
throw new TypeError(`Unknown fixture provider profile ${profile}.`);
return [selected];
}
function providerValue(
table: string,
field: FixtureField,
row: number,
random: SeededRandom,
providers: readonly FixtureProviderProfile[],
): FixtureScalar | undefined {
const eligible = field.provider
? providers.filter((provider) => provider.id === field.provider)
: providers.filter((provider) => provider.supports(field));
if (field.provider && eligible.length === 0)
throw new TypeError(
`No provider ${field.provider} is available for ${table}.${field.name}.`,
);
const provider = eligible[0];
if (!provider) return undefined;
const value = provider.generate({
table,
field,
row,
integer: (minimum, maximum) => random.integer(minimum, maximum),
pick: <T>(values: readonly T[]) => {
if (!values.length)
throw new TypeError(
`Provider ${provider.id} offered an empty value set.`,
);
return values[random.integer(0, values.length - 1)]!;
},
});
if (value === undefined) return undefined;
if (!constantMatchesType(value, field.type))
throw new TypeError(
`Provider ${provider.id} returned the wrong type for ${table}.${field.name}.`,
);
if (
typeof value === "string" &&
((field.minLength !== undefined && value.length < field.minLength) ||
(field.maxLength !== undefined && value.length > field.maxLength))
)
return undefined;
return value;
}
function boundedNumber(field: FixtureField, random: SeededRandom): number {
const minimum = field.minimum ?? 0;
const maximum = field.maximum ?? (field.type === "integer" ? 1_000 : 100);
@@ -860,13 +1224,17 @@ function uuid(random: SeededRandom): string {
}
function validValue(
table: string,
field: FixtureField,
row: number,
random: SeededRandom,
boundary: boolean,
providers: readonly FixtureProviderProfile[],
): FixtureScalar {
if (field.distribution === "constant" && field.constant !== undefined)
return field.constant;
const provided = providerValue(table, field, row, random, providers);
if (provided !== undefined) return provided;
if (field.type === "boolean")
return field.distribution === "sequential"
? row % 2 === 0
@@ -957,6 +1325,7 @@ function invalidValue(
export function generateFixtures(
input: FixtureModel,
options: GenerationOptions,
customProviders: readonly FixtureProviderProfile[] = [],
): GeneratedDataset {
const model = validateModel(structuredClone(input));
if (!options.seed || options.seed.length > 256)
@@ -983,10 +1352,18 @@ export function generateFixtures(
if (!Number.isFinite(value) || value < 0 || value > 100)
throw new RangeError("Boundary and invalid percentages must be 0100.");
const random = new SeededRandom(options.seed);
const providers = activeProviders(options.providerProfile, [
...customProviders,
...BUILTIN_PROVIDER_PROFILES,
]);
const generationOrder = topologicalTableOrder(model);
const tables: GeneratedDataset["tables"] = Object.create(
null,
) as GeneratedDataset["tables"];
for (const table of model.tables) {
for (const tableName of generationOrder) {
const table = model.tables.find(
(candidate) => candidate.name === tableName,
)!;
const seen = new Map<string, Set<string>>();
tables[table.name] = Array.from(
{ length: options.rows },
@@ -1004,10 +1381,12 @@ export function generateFixtures(
const values = seen.get(field.name) ?? new Set<string>();
do {
value = validValue(
table.name,
field,
row + attempts * options.rows,
random,
!field.unique && random.next() * 100 < options.boundaryPercent,
providers,
);
attempts += 1;
} while (
@@ -1058,6 +1437,58 @@ export function generateFixtures(
record[field.name] = targets[row % targets.length]!;
});
}
for (const table of model.tables)
for (const constraint of (table.constraints ?? []).filter(
(item): item is Extract<FixtureConstraint, { kind: "foreignKey" }> =>
item.kind === "foreignKey",
)) {
const targets = tables[constraint.references.table]!;
tables[table.name]!.forEach((record, row) => {
const target = targets[row % targets.length]!;
constraint.fields.forEach((field, index) => {
record[field] = target[constraint.references.fields[index]!]!;
});
});
}
for (const table of model.tables)
for (const constraint of (table.constraints ?? []).filter(
(item): item is Extract<FixtureConstraint, { kind: "unique" }> =>
item.kind === "unique",
)) {
const seen = new Set<string>();
for (const [row, record] of tables[table.name]!.entries()) {
let key = tupleKey(record, constraint.fields);
let attempt = 0;
while (seen.has(key) && attempt < 200) {
const fieldName = [...constraint.fields]
.reverse()
.find(
(name) =>
table.fields.find((field) => field.name === name)?.type !==
"foreignKey",
);
const field = table.fields.find(
(candidate) => candidate.name === fieldName,
);
if (!field) break;
record[field.name] = validValue(
table.name,
field,
row + (++attempt + 1) * options.rows,
random,
false,
providers,
);
key = tupleKey(record, constraint.fields);
}
if (seen.has(key))
throw new RangeError(
`Could not satisfy composite uniqueness for ${table.name}(${constraint.fields.join(", ")}).`,
);
seen.add(key);
}
}
for (const table of model.tables)
tables[table.name]!.forEach((record, row) => {
for (const field of table.fields.filter(
@@ -1076,18 +1507,121 @@ export function generateFixtures(
});
}
});
return {
for (const table of model.tables)
for (const constraint of table.constraints ?? [])
tables[table.name]!.forEach((record, row) => {
if (row === 0 || random.next() * 100 >= options.invalidPercent) return;
if (constraint.kind === "unique") {
const source = tables[table.name]![0]!;
constraint.fields.forEach((field) => {
record[field] = source[field]!;
});
} else record[constraint.fields[0]!] = "__missing_reference__";
violations.push({
table: table.name,
row: row + 1,
field: constraint.fields.join(","),
rule: constraintLabel(constraint),
});
});
const dataset: GeneratedDataset = {
seed: options.seed,
rowsPerTable: options.rows,
tables,
violations: violations.slice(0, 100_000),
generationOrder,
};
dataset.coverage = constraintCoverageMatrix(model, dataset);
return dataset;
}
function stableKey(value: FixtureScalar): string {
return `${typeof value}:${String(value)}`;
}
function tupleKey(
record: Record<string, FixtureScalar>,
fields: readonly string[],
): string {
return fields.map((field) => stableKey(record[field] ?? null)).join("\u0000");
}
function constraintLabel(constraint: FixtureConstraint): string {
return constraint.kind === "unique"
? `unique(${constraint.fields.join(",")})`
: `foreignKey(${constraint.fields.join(",")})->${constraint.references.table}(${constraint.references.fields.join(",")})`;
}
/** Auditable positive, boundary and intentional-negative coverage per rule. */
export function constraintCoverageMatrix(
input: FixtureModel,
dataset?: GeneratedDataset,
): ConstraintCoverageRow[] {
const model = validateModel(structuredClone(input));
const result: ConstraintCoverageRow[] = [];
const add = (
table: FixtureTable,
constraint: string,
boundary: (record: Record<string, FixtureScalar>) => boolean = () => false,
support: ConstraintCoverageRow["support"] = "generated-and-validated",
) => {
const rows = dataset?.tables[table.name] ?? [];
const negativeCases =
dataset?.violations.filter(
(violation) =>
violation.table === table.name &&
(violation.rule === constraint ||
(constraint.startsWith("type:") && violation.rule === constraint)),
).length ?? 0;
result.push({
table: table.name,
constraint,
support,
positiveCases: Math.max(0, rows.length - negativeCases),
boundaryCases: rows.filter(boundary).length,
negativeCases,
});
};
for (const table of model.tables) {
for (const field of table.fields) {
const value = (record: Record<string, FixtureScalar>) =>
record[field.name];
add(table, `type:${field.type}`);
if (field.required) add(table, "required");
if (field.unique) add(table, "unique");
if (field.minimum !== undefined)
add(table, "minimum", (record) => value(record) === field.minimum);
if (field.maximum !== undefined)
add(table, "maximum", (record) => value(record) === field.maximum);
if (field.minLength !== undefined)
add(
table,
"minLength",
(record) =>
typeof value(record) === "string" &&
String(value(record)).length === field.minLength,
);
if (field.maxLength !== undefined)
add(
table,
"maxLength",
(record) =>
typeof value(record) === "string" &&
String(value(record)).length === field.maxLength,
);
if (field.type === "enum") add(table, "enum");
if (field.type === "email" || field.type === "uuid")
add(table, `format:${field.type}`);
if (field.type === "foreignKey") add(table, "foreignKey");
if (field.provider)
add(table, `provider:${field.provider}`, () => false, "generated");
}
for (const constraint of table.constraints ?? [])
add(table, constraintLabel(constraint));
}
return result;
}
function safeCsvValue(value: FixtureScalar): FixtureScalar {
return typeof value === "string" && /^[\t\r\n ]*[=+\-@]/u.test(value)
? `'${value}`
+47 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.fixture-tools",
"name": "Fixture Tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Generate deterministic synthetic test data locally.",
"entry": "./",
"icon": "./favicon.svg",
@@ -21,6 +21,52 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{
"mediaType": "application/schema+json",
"extensions": [".json"]
},
{
"mediaType": "application/sql",
"extensions": [".sql"]
},
{
"mediaType": "application/xml",
"extensions": [".xsd", ".xml"]
},
{
"mediaType": "text/csv",
"extensions": [".csv"]
}
],
"produces": [
{
"mediaType": "application/json",
"extensions": [".json"]
},
{
"mediaType": "application/x-ndjson",
"extensions": [".ndjson"]
},
{
"mediaType": "text/csv",
"extensions": [".csv"]
},
{
"mediaType": "application/xml",
"extensions": [".xml"]
},
{
"mediaType": "application/sql",
"extensions": [".sql"]
}
]
},
"capabilities": {
"required": [],
"optional": []
},
"privacy": {
"processing": "local",
"fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";
+22 -1
View File
@@ -54,6 +54,27 @@ test("imports CSV headings and exports an inspectable model", async ({
expect((await pending).suggestedFilename()).toBe("fixture-model.json");
});
test("imports composite constraints and exposes topological coverage", async ({
page,
}) => {
await page.goto("/deep/nested/fixture/");
await page.getByLabel("Fixture model source").fill(`CREATE TABLE tenants (
region VARCHAR(8), tenant_id INTEGER, PRIMARY KEY (region, tenant_id)
);
CREATE TABLE events (
region VARCHAR(8), tenant_id INTEGER,
FOREIGN KEY (region, tenant_id) REFERENCES tenants(region, tenant_id)
);`);
await page.getByRole("button", { name: "Import model" }).click();
await page.getByRole("button", { name: "Generate fixtures" }).click();
await page.getByRole("button", { name: "Coverage" }).click();
await expect(
page.getByRole("heading", { name: "Constraint coverage matrix" }),
).toBeVisible();
await expect(page.getByText("tenants → events")).toBeVisible();
await expect(page.getByText(/foreignKey\(region,tenant_id\)/u)).toBeVisible();
});
test("retains results after a failed import and downloads output", async ({
page,
}) => {
@@ -97,7 +118,7 @@ test("integrates help, themes, PWA identity and hardened headers", async ({
const manifest = await request.get("/deep/nested/fixture/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.fixture-tools",
version: "0.1.0",
version: "0.2.0",
privacy: { processing: "local", telemetry: false },
});
});
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/fixture/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+102
View File
@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
import {
generateFixtures,
constraintCoverageMatrix,
modelJson,
parseFixtureModel,
serializeDataset,
topologicalTableOrder,
type GeneratedDataset,
} from "../../src/fixture/model";
@@ -175,4 +177,104 @@ describe("fixture model", () => {
}),
).toThrow(/100,000 field values/u);
});
it("imports and generates composite relational constraints topologically", () => {
const sql = `CREATE TABLE tenants (
region VARCHAR(8) NOT NULL,
tenant_id INTEGER NOT NULL,
PRIMARY KEY (region, tenant_id)
);
CREATE TABLE events (
region VARCHAR(8) NOT NULL,
tenant_id INTEGER NOT NULL,
sequence INTEGER NOT NULL,
UNIQUE (region, tenant_id, sequence),
FOREIGN KEY (region, tenant_id) REFERENCES tenants(region, tenant_id)
);`;
const model = parseFixtureModel(sql, "sql").model;
expect(model.tables[1]?.constraints).toEqual([
{ kind: "unique", fields: ["region", "tenant_id", "sequence"] },
{
kind: "foreignKey",
fields: ["region", "tenant_id"],
references: { table: "tenants", fields: ["region", "tenant_id"] },
},
]);
expect(topologicalTableOrder(model)).toEqual(["tenants", "events"]);
const dataset = generateFixtures(model, {
seed: "composite",
rows: 12,
boundaryPercent: 10,
invalidPercent: 0,
providerProfile: "auto",
});
const targets = new Set(
dataset.tables.tenants?.map((row) => `${row.region}:${row.tenant_id}`),
);
expect(
dataset.tables.events?.every((row) =>
targets.has(`${row.region}:${row.tenant_id}`),
),
).toBe(true);
expect(dataset.generationOrder).toEqual(["tenants", "events"]);
expect(
constraintCoverageMatrix(model, dataset).some(
(entry) =>
entry.table === "events" &&
entry.constraint.startsWith("foreignKey(region,tenant_id)"),
),
).toBe(true);
});
it("accepts scalar table-level primary and foreign-key constraints", () => {
const sql = `CREATE TABLE accounts (
account_id INTEGER NOT NULL,
PRIMARY KEY (account_id)
);
CREATE TABLE entries (
account_id INTEGER NOT NULL,
FOREIGN KEY (account_id) REFERENCES accounts(account_id)
);`;
const model = parseFixtureModel(sql, "sql").model;
expect(model.tables[0]?.constraints).toEqual([
{ kind: "unique", fields: ["account_id"] },
]);
expect(model.tables[1]?.constraints).toEqual([
{
kind: "foreignKey",
fields: ["account_id"],
references: { table: "accounts", fields: ["account_id"] },
},
]);
const dataset = generateFixtures(model, {
seed: "scalar-table-constraints",
rows: 8,
boundaryPercent: 0,
invalidPercent: 0,
});
const accounts = new Set(
dataset.tables.accounts?.map((row) => row.account_id),
);
expect(
dataset.tables.entries?.every((row) => accounts.has(row.account_id)),
).toBe(true);
});
it("supports deterministic built-in and custom provider profiles", () => {
const model = parseFixtureModel("display_name,host,sku\n", "csv").model;
const options = {
seed: "providers",
rows: 4,
boundaryPercent: 0,
invalidPercent: 0,
providerProfile: "auto",
};
const generated = generateFixtures(model, options);
expect(generated).toEqual(generateFixtures(model, options));
expect(generated.tables.records?.[0]).toMatchObject({
display_name: expect.stringMatching(/ /u),
host: expect.stringMatching(/\.example\.test$/u),
sku: "SKU-000001",
});
});
});