Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1c41e7079 |
@@ -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
|
||||
@@ -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,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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
Generated
+19
-19
@@ -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
@@ -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
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+604
-70
@@ -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 1–16 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,74 +458,123 @@ function fromFixtureModel(source: string): ParsedModel {
|
||||
!Array.isArray(table.fields)
|
||||
)
|
||||
throw new TypeError(`Fixture table ${tableIndex + 1} is invalid.`);
|
||||
const fields = table.fields.map((rawField, fieldIndex) => {
|
||||
const field = object(rawField);
|
||||
if (
|
||||
!field ||
|
||||
typeof field.name !== "string" ||
|
||||
typeof field.type !== "string"
|
||||
)
|
||||
throw new TypeError(
|
||||
`Fixture field ${table.name}.${fieldIndex + 1} is invalid.`,
|
||||
);
|
||||
if (field.required !== undefined && typeof field.required !== "boolean")
|
||||
throw new TypeError(
|
||||
`${table.name}.${field.name} required must be boolean.`,
|
||||
);
|
||||
if (field.unique !== undefined && typeof field.unique !== "boolean")
|
||||
throw new TypeError(
|
||||
`${table.name}.${field.name} unique must be boolean.`,
|
||||
);
|
||||
if (
|
||||
field.distribution !== undefined &&
|
||||
field.distribution !== "uniform" &&
|
||||
field.distribution !== "sequential" &&
|
||||
field.distribution !== "normal" &&
|
||||
field.distribution !== "constant"
|
||||
)
|
||||
throw new TypeError(
|
||||
`${table.name}.${field.name} has an unsupported distribution.`,
|
||||
);
|
||||
if (
|
||||
field.values !== undefined &&
|
||||
(!Array.isArray(field.values) || !field.values.every(isScalar))
|
||||
)
|
||||
throw new TypeError(
|
||||
`${table.name}.${field.name} enum values must be scalar JSON values.`,
|
||||
);
|
||||
if (field.constant !== undefined && !isScalar(field.constant))
|
||||
throw new TypeError(
|
||||
`${table.name}.${field.name} constant must be a scalar JSON value.`,
|
||||
);
|
||||
return {
|
||||
name: field.name,
|
||||
type: field.type as FixtureType,
|
||||
required: field.required === true,
|
||||
unique: field.unique === true,
|
||||
distribution: (field.distribution ?? "uniform") as Distribution,
|
||||
...(numeric(field.minimum) !== undefined
|
||||
? { minimum: numeric(field.minimum) }
|
||||
: {}),
|
||||
...(numeric(field.maximum) !== undefined
|
||||
? { maximum: numeric(field.maximum) }
|
||||
: {}),
|
||||
...(integer(field.minLength) !== undefined
|
||||
? { minLength: integer(field.minLength) }
|
||||
: {}),
|
||||
...(integer(field.maxLength) !== undefined
|
||||
? { maxLength: integer(field.maxLength) }
|
||||
: {}),
|
||||
...(Array.isArray(field.values)
|
||||
? { values: field.values.slice(0, 1_000) as FixtureScalar[] }
|
||||
: {}),
|
||||
...(typeof field.reference === "string"
|
||||
? { 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: table.fields.map((rawField, fieldIndex) => {
|
||||
const field = object(rawField);
|
||||
if (
|
||||
!field ||
|
||||
typeof field.name !== "string" ||
|
||||
typeof field.type !== "string"
|
||||
)
|
||||
throw new TypeError(
|
||||
`Fixture field ${table.name}.${fieldIndex + 1} is invalid.`,
|
||||
);
|
||||
if (field.required !== undefined && typeof field.required !== "boolean")
|
||||
throw new TypeError(
|
||||
`${table.name}.${field.name} required must be boolean.`,
|
||||
);
|
||||
if (field.unique !== undefined && typeof field.unique !== "boolean")
|
||||
throw new TypeError(
|
||||
`${table.name}.${field.name} unique must be boolean.`,
|
||||
);
|
||||
if (
|
||||
field.distribution !== undefined &&
|
||||
field.distribution !== "uniform" &&
|
||||
field.distribution !== "sequential" &&
|
||||
field.distribution !== "normal" &&
|
||||
field.distribution !== "constant"
|
||||
)
|
||||
throw new TypeError(
|
||||
`${table.name}.${field.name} has an unsupported distribution.`,
|
||||
);
|
||||
if (
|
||||
field.values !== undefined &&
|
||||
(!Array.isArray(field.values) || !field.values.every(isScalar))
|
||||
)
|
||||
throw new TypeError(
|
||||
`${table.name}.${field.name} enum values must be scalar JSON values.`,
|
||||
);
|
||||
if (field.constant !== undefined && !isScalar(field.constant))
|
||||
throw new TypeError(
|
||||
`${table.name}.${field.name} constant must be a scalar JSON value.`,
|
||||
);
|
||||
return {
|
||||
name: field.name,
|
||||
type: field.type as FixtureType,
|
||||
required: field.required === true,
|
||||
unique: field.unique === true,
|
||||
distribution: (field.distribution ?? "uniform") as Distribution,
|
||||
...(numeric(field.minimum) !== undefined
|
||||
? { minimum: numeric(field.minimum) }
|
||||
: {}),
|
||||
...(numeric(field.maximum) !== undefined
|
||||
? { maximum: numeric(field.maximum) }
|
||||
: {}),
|
||||
...(integer(field.minLength) !== undefined
|
||||
? { minLength: integer(field.minLength) }
|
||||
: {}),
|
||||
...(integer(field.maxLength) !== undefined
|
||||
? { maxLength: integer(field.maxLength) }
|
||||
: {}),
|
||||
...(Array.isArray(field.values)
|
||||
? { values: field.values.slice(0, 1_000) as FixtureScalar[] }
|
||||
: {}),
|
||||
...(typeof field.reference === "string"
|
||||
? { reference: field.reference }
|
||||
: {}),
|
||||
...(isScalar(field.constant) ? { constant: field.constant } : {}),
|
||||
};
|
||||
}),
|
||||
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 0–100.");
|
||||
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}`
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user