Release Fixture Tools 0.1.0

This commit is contained in:
2026-09-01 13:03:32 +02:00
commit f5d018c64f
57 changed files with 9981 additions and 0 deletions
+823
View File
@@ -0,0 +1,823 @@
import { useMemo, useState, type ChangeEvent } from "react";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import {
generateFixtures,
modelJson,
parseFixtureModel,
serializeDataset,
validateModel,
type Distribution,
type FixtureField,
type FixtureModel,
type FixtureType,
type GeneratedDataset,
type ModelFormat,
type OutputFormat,
} from "../fixture/model";
const SAMPLE_SQL = `CREATE TABLE users (
id INTEGER PRIMARY KEY,
email VARCHAR(80) NOT NULL UNIQUE,
display_name VARCHAR(48) NOT NULL,
active BOOLEAN NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
amount DECIMAL(10,2) NOT NULL,
ordered_at TIMESTAMP NOT NULL
);`;
const initialParsed = parseFixtureModel(SAMPLE_SQL, "sql");
const initialDataset = generateFixtures(initialParsed.model, {
seed: "fixture-tools-demo",
rows: 8,
boundaryPercent: 10,
invalidPercent: 0,
});
type View = "model" | "data" | "violations";
const TYPES: FixtureType[] = [
"string",
"integer",
"number",
"boolean",
"date",
"datetime",
"email",
"uuid",
"enum",
"foreignKey",
];
const DISTRIBUTIONS: Distribution[] = [
"uniform",
"sequential",
"normal",
"constant",
];
function download(text: string, filename: string, mediaType: string) {
triggerBlobDownload(new Blob([text], { type: mediaType }), filename);
}
function replaceField(
model: FixtureModel,
tableIndex: number,
fieldIndex: number,
changes: Partial<FixtureField>,
): FixtureModel {
return {
tables: model.tables.map((table, currentTable) =>
currentTable === tableIndex
? {
...table,
fields: table.fields.map((field, currentField) =>
currentField === fieldIndex ? { ...field, ...changes } : field,
),
}
: table,
),
};
}
function FieldEditor({
field,
onChange,
onRemove,
}: {
field: FixtureField;
onChange: (changes: Partial<FixtureField>) => void;
onRemove: () => void;
}) {
return (
<tr>
<td>
<input
aria-label={`${field.name} field name`}
value={field.name}
onChange={(event) => onChange({ name: event.target.value })}
/>
</td>
<td>
<select
aria-label={`${field.name} type`}
value={field.type}
onChange={(event) => {
const type = event.target.value as FixtureType;
onChange({
type,
reference:
type === "foreignKey" ? (field.reference ?? "") : undefined,
values: type === "enum" ? (field.values ?? []) : undefined,
constant: undefined,
distribution:
type === "foreignKey" || field.distribution === "constant"
? "uniform"
: field.distribution,
});
}}
>
{TYPES.map((type) => (
<option key={type}>{type}</option>
))}
</select>
</td>
<td>
<select
aria-label={`${field.name} distribution`}
value={field.distribution}
disabled={field.type === "foreignKey"}
onChange={(event) => {
const distribution = event.target.value as Distribution;
onChange({
distribution,
...(distribution === "constant" ? {} : { constant: undefined }),
});
}}
>
{DISTRIBUTIONS.map((distribution) => (
<option key={distribution}>{distribution}</option>
))}
</select>
</td>
<td className="flag-cell">
<label>
<input
type="checkbox"
checked={field.required}
onChange={(event) => onChange({ required: event.target.checked })}
/>
required
</label>
<label>
<input
type="checkbox"
checked={field.unique}
onChange={(event) => onChange({ unique: event.target.checked })}
/>
unique
</label>
</td>
<td>
{field.type === "foreignKey" ? (
<input
aria-label={`${field.name} reference`}
placeholder="table.field"
value={field.reference ?? ""}
onChange={(event) => onChange({ reference: event.target.value })}
/>
) : field.type === "enum" ? (
<span className="range-inputs">
<input
aria-label={`${field.name} enum values`}
placeholder="draft, active, closed"
value={(field.values ?? []).join(", ")}
onChange={(event) =>
onChange({
values: event.target.value
.split(",")
.map((value) => value.trim())
.filter(Boolean),
})
}
/>
{field.distribution === "constant" ? (
<select
aria-label={`${field.name} constant`}
value={(field.values ?? []).findIndex(
(value) => value === field.constant,
)}
onChange={(event) =>
onChange({
constant:
field.values?.[Number(event.target.value)] ?? undefined,
})
}
>
<option value="-1">Choose constant</option>
{(field.values ?? []).map((value, index) => (
<option value={index} key={`${String(value)}-${index}`}>
{String(value)}
</option>
))}
</select>
) : null}
</span>
) : field.distribution === "constant" ? (
field.type === "boolean" ? (
<select
aria-label={`${field.name} constant`}
value={
typeof field.constant === "boolean"
? String(field.constant)
: ""
}
onChange={(event) =>
onChange({
constant:
event.target.value === ""
? undefined
: event.target.value === "true",
})
}
>
<option value="">Choose constant</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : (
<input
aria-label={`${field.name} constant`}
type={
field.type === "integer" || field.type === "number"
? "number"
: field.type === "date"
? "date"
: "text"
}
step={field.type === "integer" ? "1" : undefined}
placeholder="constant value"
value={field.constant == null ? "" : String(field.constant)}
onChange={(event) =>
onChange({
constant:
event.target.value === ""
? undefined
: field.type === "integer" || field.type === "number"
? Number(event.target.value)
: event.target.value,
})
}
/>
)
) : field.type === "integer" || field.type === "number" ? (
<span className="range-inputs">
<input
aria-label={`${field.name} minimum`}
type="number"
placeholder="min"
value={field.minimum ?? ""}
onChange={(event) =>
onChange({
minimum: event.target.value
? Number(event.target.value)
: undefined,
})
}
/>
<input
aria-label={`${field.name} maximum`}
type="number"
placeholder="max"
value={field.maximum ?? ""}
onChange={(event) =>
onChange({
maximum: event.target.value
? Number(event.target.value)
: undefined,
})
}
/>
</span>
) : (
<span className="range-inputs">
<input
aria-label={`${field.name} minimum length`}
type="number"
min="0"
placeholder="min length"
value={field.minLength ?? ""}
onChange={(event) =>
onChange({
minLength: event.target.value
? Number(event.target.value)
: undefined,
})
}
/>
<input
aria-label={`${field.name} maximum length`}
type="number"
min="0"
placeholder="max length"
value={field.maxLength ?? ""}
onChange={(event) =>
onChange({
maxLength: event.target.value
? Number(event.target.value)
: undefined,
})
}
/>
</span>
)}
</td>
<td>
<button className="icon-button danger" type="button" onClick={onRemove}>
Remove
</button>
</td>
</tr>
);
}
export function Workbench() {
const [source, setSource] = useState(SAMPLE_SQL);
const [sourceName, setSourceName] = useState("shop.sql");
const [inputFormat, setInputFormat] = useState<ModelFormat>("sql");
const [model, setModel] = useState<FixtureModel>(initialParsed.model);
const [notices, setNotices] = useState(initialParsed.notices);
const [dataset, setDataset] = useState<GeneratedDataset>(initialDataset);
const [seed, setSeed] = useState("fixture-tools-demo");
const [rows, setRows] = useState(8);
const [boundaryPercent, setBoundaryPercent] = useState(10);
const [invalidPercent, setInvalidPercent] = useState(0);
const [selectedTable, setSelectedTable] = useState(model.tables[0]!.name);
const [outputFormat, setOutputFormat] = useState<OutputFormat>("json");
const [view, setView] = useState<View>("data");
const [error, setError] = useState<string>();
const tableIndex = Math.max(
0,
model.tables.findIndex((table) => table.name === selectedTable),
);
const table = model.tables[tableIndex]!;
const records = dataset.tables[selectedTable] ?? [];
const output = useMemo(() => {
try {
return { text: serializeDataset(dataset, outputFormat, selectedTable) };
} catch (reason) {
return {
text: "",
error: reason instanceof Error ? reason.message : "Output failed.",
};
}
}, [dataset, outputFormat, selectedTable]);
const parseSource = () => {
try {
const parsed = parseFixtureModel(source, inputFormat);
setModel(parsed.model);
setNotices(parsed.notices);
setSelectedTable(parsed.model.tables[0]!.name);
setError(undefined);
setView("model");
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: "The model could not be parsed.",
);
}
};
const generate = () => {
try {
const validated = validateModel(structuredClone(model));
const next = generateFixtures(validated, {
seed,
rows,
boundaryPercent,
invalidPercent,
});
setModel(validated);
setDataset(next);
setError(undefined);
setView("data");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Generation failed.");
}
};
const openFile = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
setError("Model file exceeds 2 MiB.");
return;
}
const extension = file.name.split(".").at(-1)?.toLowerCase();
const format: ModelFormat = file.name
.toLowerCase()
.endsWith(".fixture.json")
? "fixture-model"
: extension === "sql"
? "sql"
: extension === "xsd"
? "xsd"
: extension === "csv"
? "csv"
: "json-schema";
setSource(await file.text());
setSourceName(file.name);
setInputFormat(format);
setError(undefined);
};
const updateCurrentField = (
fieldIndex: number,
changes: Partial<FixtureField>,
) =>
setModel((current) =>
replaceField(current, tableIndex, fieldIndex, changes),
);
const extension =
outputFormat === "json"
? "json"
: outputFormat === "ndjson"
? "ndjson"
: outputFormat === "xml"
? "xml"
: outputFormat === "sql"
? "sql"
: "csv";
return (
<main className="workbench">
<section className="hero">
<div>
<p className="eyebrow">Deterministic synthetic-data laboratory</p>
<h1>Fixture Tools</h1>
<p>
Turn local schemas into replayable, privacy-safe datasetswith
foreign keys, boundary values and intentionally invalid cases kept
explicit.
</p>
</div>
<span className="privacy-pill">Seeded · local only</span>
</section>
<section className="panel source-panel" aria-labelledby="source-title">
<div className="panel-heading">
<div>
<h2 id="source-title">Model source</h2>
<p>
{sourceName} · the last valid model remains active after an error
</p>
</div>
<label className="button file-button">
Open model
<input
data-testid="model-file-input"
type="file"
accept=".json,.schema.json,.sql,.xsd,.csv,application/json,text/csv,text/xml"
onChange={(event) => void openFile(event)}
/>
</label>
</div>
<div className="source-grid">
<label>
Source kind
<select
aria-label="Source kind"
value={inputFormat}
onChange={(event) =>
setInputFormat(event.target.value as ModelFormat)
}
>
<option value="fixture-model">Fixture model JSON</option>
<option value="json-schema">JSON Schema</option>
<option value="sql">SQL DDL</option>
<option value="xsd">XSD</option>
<option value="csv">CSV headings</option>
</select>
</label>
<button
className="primary-button"
type="button"
onClick={parseSource}
>
Import model
</button>
</div>
<textarea
aria-label="Fixture model source"
value={source}
onChange={(event) => setSource(event.target.value)}
rows={10}
spellCheck={false}
/>
{error ? (
<p className="error" role="alert">
{error} Existing model and generated data were retained.
</p>
) : (
<p className="success" role="status">
{model.tables.length} table{model.tables.length === 1 ? "" : "s"} ·{" "}
{model.tables.reduce((sum, item) => sum + item.fields.length, 0)}{" "}
fields · {dataset.violations.length} intentional violations
</p>
)}
{notices.slice(0, 8).map((notice) => (
<p className="warning" key={notice}>
{notice}
</p>
))}
</section>
<section
className="panel generator-panel"
aria-labelledby="generator-title"
>
<div className="panel-heading">
<div>
<h2 id="generator-title">Generation recipe</h2>
<p>Same model, seed and settings produce the same records.</p>
</div>
<button
type="button"
onClick={() =>
download(
modelJson(model),
"fixture-model.json",
"application/json",
)
}
>
Export model
</button>
</div>
<div className="option-grid">
<label>
Seed
<input
value={seed}
maxLength={256}
onChange={(event) => setSeed(event.target.value)}
/>
</label>
<label>
Rows per table
<input
type="number"
min="1"
max="10000"
value={rows}
onChange={(event) => setRows(Number(event.target.value))}
/>
</label>
<label>
Boundary values (%)
<input
type="number"
min="0"
max="100"
value={boundaryPercent}
onChange={(event) =>
setBoundaryPercent(Number(event.target.value))
}
/>
</label>
<label>
Intentional invalids (%)
<input
type="number"
min="0"
max="100"
value={invalidPercent}
onChange={(event) =>
setInvalidPercent(Number(event.target.value))
}
/>
</label>
<button className="primary-button" type="button" onClick={generate}>
Generate fixtures
</button>
</div>
</section>
<nav className="tabs" aria-label="Fixture workspaces">
{(["model", "data", "violations"] as View[]).map((name) => (
<button
type="button"
key={name}
aria-pressed={view === name}
onClick={() => setView(name)}
>
{name === "violations"
? `Invalid cases (${dataset.violations.length})`
: name[0]!.toUpperCase() + name.slice(1)}
</button>
))}
</nav>
{view === "model" ? (
<section className="panel">
<div className="panel-heading">
<div>
<h2>Editable field model</h2>
<p>
Imported constructs are normalized into this inspectable model.
</p>
</div>
<label>
Table
<select
aria-label="Model table"
value={selectedTable}
onChange={(event) => setSelectedTable(event.target.value)}
>
{model.tables.map((item) => (
<option key={item.name}>{item.name}</option>
))}
</select>
</label>
</div>
<div className="table-scroll" tabIndex={0}>
<table className="field-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Distribution</th>
<th>Rules</th>
<th>Bounds / values / reference</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{table.fields.map((field, fieldIndex) => (
<FieldEditor
key={`${field.name}-${fieldIndex}`}
field={field}
onChange={(changes) =>
updateCurrentField(fieldIndex, changes)
}
onRemove={() =>
setModel((current) => ({
tables: current.tables.map((item, currentIndex) =>
currentIndex === tableIndex
? {
...item,
fields: item.fields.filter(
(_field, index) => index !== fieldIndex,
),
}
: item,
),
}))
}
/>
))}
</tbody>
</table>
</div>
<button
type="button"
className="top-gap"
onClick={() =>
setModel((current) => ({
tables: current.tables.map((item, currentIndex) =>
currentIndex === tableIndex
? {
...item,
fields: [
...item.fields,
{
name: `field_${item.fields.length + 1}`,
type: "string",
required: true,
unique: false,
distribution: "sequential",
},
],
}
: item,
),
}))
}
>
Add field
</button>
</section>
) : null}
{view === "data" ? (
<section className="workspace-grid">
<article className="panel">
<div className="panel-heading">
<div>
<h2>Generated records</h2>
<p>
Showing at most 100 of {records.length.toLocaleString()} rows.
</p>
</div>
<select
aria-label="Data table"
value={selectedTable}
onChange={(event) => setSelectedTable(event.target.value)}
>
{Object.keys(dataset.tables).map((name) => (
<option key={name}>{name}</option>
))}
</select>
</div>
<div className="table-scroll records" tabIndex={0}>
<table>
<thead>
<tr>
<th>#</th>
{Object.keys(records[0] ?? {}).map((name) => (
<th key={name}>{name}</th>
))}
</tr>
</thead>
<tbody>
{records.slice(0, 100).map((record, index) => (
<tr key={index}>
<th>{index + 1}</th>
{Object.keys(record).map((name) => (
<td key={name}>{String(record[name] ?? "null")}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</article>
<article className="panel export-panel">
<div className="panel-heading">
<h2>Export</h2>
<label>
Format
<select
aria-label="Output format"
value={outputFormat}
onChange={(event) =>
setOutputFormat(event.target.value as OutputFormat)
}
>
<option value="json">JSON</option>
<option value="csv">CSV (selected table)</option>
<option value="ndjson">NDJSON</option>
<option value="xml">XML</option>
<option value="sql">SQL INSERT</option>
</select>
</label>
</div>
{output.error ? <p className="error">{output.error}</p> : null}
<textarea
aria-label="Generated output"
readOnly
rows={24}
value={output.text}
/>
<button
className="primary-button top-gap"
type="button"
disabled={!output.text}
onClick={() =>
download(
output.text,
`fixtures.${extension}`,
outputFormat === "json" || outputFormat === "ndjson"
? "application/json"
: outputFormat === "xml"
? "application/xml"
: "text/plain",
)
}
>
Download {extension.toUpperCase()}
</button>
</article>
</section>
) : null}
{view === "violations" ? (
<section className="panel">
<div className="panel-heading">
<div>
<h2>Intentional invalid cases</h2>
<p>
Every mutation requested by the invalid percentage is recorded;
zero means all generated values satisfy the focused model.
</p>
</div>
<span className="count-pill">{dataset.violations.length}</span>
</div>
{dataset.violations.length ? (
<ul className="violation-list">
{dataset.violations.slice(0, 1_000).map((violation, index) => (
<li
key={`${violation.table}-${violation.row}-${violation.field}-${index}`}
>
<code>
{violation.table}[{violation.row}].{violation.field}
</code>
<strong>{violation.rule}</strong>
</li>
))}
</ul>
) : (
<p className="empty">No invalid cases were requested.</p>
)}
</section>
) : null}
</main>
);
}