885 lines
28 KiB
TypeScript
885 lines
28 KiB
TypeScript
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" | "coverage" | "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 [providerProfile, setProviderProfile] = useState("auto");
|
|
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,
|
|
providerProfile,
|
|
});
|
|
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 datasets—with
|
|
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>
|
|
<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>
|
|
</div>
|
|
</section>
|
|
|
|
<nav className="tabs" aria-label="Fixture workspaces">
|
|
{(["model", "data", "coverage", "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}
|
|
|
|
{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>
|
|
);
|
|
}
|