feat(us1): add dataset review orchestration automatic review slice

This commit is contained in:
2026-03-17 10:57:49 +03:00
parent e916cb1f17
commit 023bacde39
24 changed files with 4870 additions and 131 deletions

View File

@@ -0,0 +1,332 @@
<!-- [DEF:SourceIntakePanel:Component] -->
<!-- @COMPLEXITY: 3 -->
<!-- @SEMANTICS: dataset-review, intake, superset-link, dataset-selection, validation -->
<!-- @PURPOSE: Collect initial dataset source input through Superset link paste or dataset selection entry paths. -->
<!-- @LAYER: UI -->
<!-- @RELATION: [BINDS_TO] ->[api_module] -->
<!-- @UX_STATE: Idle -> Empty intake form with two clear entry paths. -->
<!-- @UX_STATE: Validating -> Lightweight inline validation feedback. -->
<!-- @UX_STATE: Rejected -> Input error shown with corrective hint. -->
<!-- @UX_FEEDBACK: Recognized links are acknowledged before deeper recovery finishes. -->
<!-- @UX_RECOVERY: Users can correct invalid input in place without resetting the page. -->
<!-- @UX_REACTIVITY: Uses $props, $state, and $derived only; no legacy reactive syntax. -->
<script>
import { t } from "$lib/i18n";
let {
environments = [],
selectedEnvironmentId = "",
initialSourceKind = "superset_link",
initialSourceInput = "",
submitting = false,
acknowledgment = "",
onsubmit = async () => {},
} = $props();
let sourceKind = $state("superset_link");
let sourceInput = $state("");
let environmentId = $state("");
let touched = $state(false);
let localError = $state("");
let submitAttempted = $state(false);
const normalizedSourceInput = $derived(sourceInput.trim());
const normalizedEnvironmentId = $derived(String(environmentId || "").trim());
const isSupersetLinkMode = $derived(sourceKind === "superset_link");
const linkLooksRecognized = $derived(
/^https?:\/\/.+\/superset\/|^https?:\/\/.+\/explore\/|^https?:\/\/.+\/dashboard\//i.test(
normalizedSourceInput,
),
);
const intakeState = $derived(
localError
? "Rejected"
: submitting || touched
? "Validating"
: "Idle",
);
const canSubmit = $derived(
normalizedEnvironmentId.length > 0 && normalizedSourceInput.length > 0 && !submitting,
);
$effect(() => {
if (!touched && !submitAttempted) {
sourceKind = initialSourceKind || "superset_link";
}
if (!environmentId) {
environmentId = selectedEnvironmentId || "";
}
if (!sourceInput) {
sourceInput = initialSourceInput || "";
}
});
function setMode(nextMode) {
sourceKind = nextMode;
localError = "";
touched = false;
submitAttempted = false;
}
function getInlineHint() {
if (!normalizedEnvironmentId) {
return $t.dataset_review?.source?.environment_required;
}
if (!normalizedSourceInput) {
return isSupersetLinkMode
? $t.dataset_review?.source?.superset_link_hint
: $t.dataset_review?.source?.dataset_selection_hint;
}
if (localError) {
return localError;
}
if (isSupersetLinkMode && linkLooksRecognized) {
return (
acknowledgment ||
$t.dataset_review?.source?.recognized_link_hint ||
$t.dataset_review?.source?.superset_link_hint
);
}
return isSupersetLinkMode
? $t.dataset_review?.source?.superset_link_hint
: $t.dataset_review?.source?.dataset_selection_hint;
}
function validatePayload() {
if (!normalizedEnvironmentId) {
return $t.dataset_review?.source?.environment_required;
}
if (!normalizedSourceInput) {
return isSupersetLinkMode
? $t.dataset_review?.source?.superset_link_required
: $t.dataset_review?.source?.dataset_selection_required;
}
if (isSupersetLinkMode && !/^https?:\/\//i.test(normalizedSourceInput)) {
return $t.dataset_review?.source?.superset_link_invalid;
}
return "";
}
async function submitPayload() {
touched = true;
submitAttempted = true;
localError = validatePayload();
if (localError) {
return;
}
try {
await onsubmit({
source_kind: sourceKind,
source_input: normalizedSourceInput,
environment_id: normalizedEnvironmentId,
});
localError = "";
} catch (error) {
localError =
error?.message ||
$t.dataset_review?.source?.submit_failed ||
$t.common?.error;
}
}
function handleInput() {
touched = true;
if (submitAttempted) {
localError = validatePayload();
}
}
async function submitSupersetLink(event) {
event?.preventDefault?.();
sourceKind = "superset_link";
await submitPayload();
}
async function submitDatasetSelection(event) {
event?.preventDefault?.();
sourceKind = "dataset_selection";
await submitPayload();
}
async function handleSubmit(event) {
if (isSupersetLinkMode) {
await submitSupersetLink(event);
return;
}
await submitDatasetSelection(event);
}
</script>
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div class="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-blue-700">
{$t.dataset_review?.source?.eyebrow}
</p>
<h2 class="text-xl font-semibold text-slate-900">
{$t.dataset_review?.source?.title}
</h2>
<p class="mt-1 max-w-2xl text-sm text-slate-600">
{$t.dataset_review?.source?.description}
</p>
</div>
<div
class={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${
intakeState === "Rejected"
? "bg-red-100 text-red-700"
: intakeState === "Validating"
? "bg-amber-100 text-amber-700"
: "bg-slate-100 text-slate-700"
}`}
>
{#if intakeState === "Rejected"}
{$t.dataset_review?.source?.state_rejected}
{:else if intakeState === "Validating"}
{$t.dataset_review?.source?.state_validating}
{:else}
{$t.dataset_review?.source?.state_idle}
{/if}
</div>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-2">
<button
type="button"
class={`rounded-xl border px-4 py-3 text-left transition ${
isSupersetLinkMode
? "border-blue-500 bg-blue-50 text-blue-900 shadow-sm"
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50"
}`}
onclick={() => setMode("superset_link")}
>
<div class="text-sm font-semibold">
{$t.dataset_review?.source?.superset_link_tab}
</div>
<div class="mt-1 text-xs text-slate-500">
{$t.dataset_review?.source?.superset_link_tab_hint}
</div>
</button>
<button
type="button"
class={`rounded-xl border px-4 py-3 text-left transition ${
!isSupersetLinkMode
? "border-blue-500 bg-blue-50 text-blue-900 shadow-sm"
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50"
}`}
onclick={() => setMode("dataset_selection")}
>
<div class="text-sm font-semibold">
{$t.dataset_review?.source?.dataset_selection_tab}
</div>
<div class="mt-1 text-xs text-slate-500">
{$t.dataset_review?.source?.dataset_selection_tab_hint}
</div>
</button>
</div>
<form class="mt-5 space-y-4" onsubmit={handleSubmit}>
<div class="grid gap-4 lg:grid-cols-[minmax(0,14rem)_minmax(0,1fr)]">
<label class="space-y-2">
<span class="text-sm font-medium text-slate-700">
{$t.dataset_review?.source?.environment_label}
</span>
<select
class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
bind:value={environmentId}
onchange={handleInput}
>
<option value="">{$t.common?.choose_environment}</option>
{#each environments as environment}
<option value={environment.id}>{environment.name}</option>
{/each}
</select>
</label>
<label class="space-y-2">
<span class="text-sm font-medium text-slate-700">
{isSupersetLinkMode
? $t.dataset_review?.source?.superset_link_label
: $t.dataset_review?.source?.dataset_selection_label}
</span>
{#if isSupersetLinkMode}
<input
class={`w-full rounded-xl border px-3 py-2 text-sm text-slate-900 shadow-sm outline-none transition focus:ring-2 ${
localError
? "border-red-300 bg-red-50 focus:border-red-400 focus:ring-red-100"
: "border-slate-300 bg-white focus:border-blue-500 focus:ring-blue-200"
}`}
type="url"
placeholder={$t.dataset_review?.source?.superset_link_placeholder}
bind:value={sourceInput}
oninput={handleInput}
/>
{:else}
<input
class={`w-full rounded-xl border px-3 py-2 text-sm text-slate-900 shadow-sm outline-none transition focus:ring-2 ${
localError
? "border-red-300 bg-red-50 focus:border-red-400 focus:ring-red-100"
: "border-slate-300 bg-white focus:border-blue-500 focus:ring-blue-200"
}`}
type="text"
placeholder={$t.dataset_review?.source?.dataset_selection_placeholder}
bind:value={sourceInput}
oninput={handleInput}
/>
{/if}
</label>
</div>
<div
class={`rounded-xl border px-4 py-3 text-sm ${
localError
? "border-red-200 bg-red-50 text-red-700"
: linkLooksRecognized && isSupersetLinkMode
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-slate-200 bg-slate-50 text-slate-600"
}`}
>
{getInlineHint()}
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="text-xs text-slate-500">
{#if isSupersetLinkMode}
{$t.dataset_review?.source?.superset_link_recovery_note}
{:else}
{$t.dataset_review?.source?.dataset_selection_recovery_note}
{/if}
</div>
<button
type="submit"
class="inline-flex items-center justify-center rounded-xl bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-slate-300"
disabled={!canSubmit}
>
{#if submitting}
{$t.dataset_review?.source?.submitting}
{:else if isSupersetLinkMode}
{$t.dataset_review?.source?.submit_superset_link}
{:else}
{$t.dataset_review?.source?.submit_dataset_selection}
{/if}
</button>
</div>
</form>
</div>
<!-- [/DEF:SourceIntakePanel:Component] -->

View File

@@ -0,0 +1,332 @@
<!-- [DEF:ValidationFindingsPanel:Component] -->
<!-- @COMPLEXITY: 3 -->
<!-- @SEMANTICS: dataset-review, findings, severity, readiness, actionability -->
<!-- @PURPOSE: Present validation findings grouped by severity with explicit resolution and actionability signals. -->
<!-- @LAYER: UI -->
<!-- @RELATION: [BINDS_TO] ->[DatasetReviewWorkspace] -->
<!-- @UX_STATE: Blocking -> Blocking findings are visually dominant and block launch flow. -->
<!-- @UX_STATE: Warning -> Warnings remain visible with explicit approval or defer actions. -->
<!-- @UX_STATE: Informational -> Low-priority findings are collapsed or secondary. -->
<!-- @UX_FEEDBACK: Resolving or approving an item updates readiness state immediately. -->
<!-- @UX_RECOVERY: Users can jump from a finding directly to the relevant remediation area. -->
<!-- @UX_REACTIVITY: Uses $props and $derived only; no legacy reactive syntax. -->
<script>
import { t } from "$lib/i18n";
let {
findings = [],
recommendedAction = "",
onjump = () => {},
} = $props();
const groupedFindings = $derived(groupFindingsBySeverity(findings || []));
const blockingFindings = $derived(groupedFindings.blocking);
const warningFindings = $derived(groupedFindings.warning);
const informationalFindings = $derived(groupedFindings.informational);
const totalFindings = $derived(
blockingFindings.length + warningFindings.length + informationalFindings.length,
);
function groupFindingsBySeverity(items) {
return (items || []).reduce(
(accumulator, finding) => {
const severity = String(finding?.severity || "informational");
if (severity === "blocking") {
accumulator.blocking.push(finding);
return accumulator;
}
if (severity === "warning") {
accumulator.warning.push(finding);
return accumulator;
}
accumulator.informational.push(finding);
return accumulator;
},
{ blocking: [], warning: [], informational: [] },
);
}
function buildTargetFromFinding(finding) {
const area = String(finding?.area || "");
if (area === "source_intake") return "intake";
if (area === "dataset_profile") return "summary";
if (area === "semantic_enrichment") return "semantics";
if (area === "clarification") return "clarification";
if (area === "filter_recovery") return "filters";
if (area === "template_mapping") return "mappings";
if (area === "compiled_preview") return "preview";
if (area === "launch") return "launch";
return "summary";
}
function jumpToFindingTarget(finding) {
onjump({
target: buildTargetFromFinding(finding),
finding,
});
}
function getResolutionLabel(resolutionState) {
const normalized = String(resolutionState || "open");
return $t.dataset_review?.findings?.resolution?.[normalized] || normalized;
}
function getAreaLabel(area) {
const normalized = String(area || "dataset_profile");
return $t.dataset_review?.findings?.areas?.[normalized] || normalized;
}
function getRecommendedActionLabel(action) {
const normalized = String(action || "");
return $t.dataset_review?.workspace?.actions?.[normalized] || normalized;
}
</script>
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
{$t.dataset_review?.findings?.eyebrow}
</p>
<h2 class="text-xl font-semibold text-slate-900">
{$t.dataset_review?.findings?.title}
</h2>
<p class="mt-1 max-w-3xl text-sm text-slate-600">
{$t.dataset_review?.findings?.description}
</p>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-right">
<div class="text-xs uppercase tracking-wide text-slate-500">
{$t.dataset_review?.findings?.next_action_label}
</div>
<div class="mt-1 text-sm font-medium text-slate-900">
{getRecommendedActionLabel(recommendedAction)}
</div>
</div>
</div>
{#if totalFindings === 0}
<div class="mt-5 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-800">
{$t.dataset_review?.findings?.empty}
</div>
{:else}
<div class="mt-5 space-y-4">
<section class="rounded-2xl border border-red-200 bg-red-50 p-4">
<div class="flex items-center gap-3">
<h3 class="text-sm font-semibold text-slate-900">
{$t.dataset_review?.findings?.blocking_title}
</h3>
<span class="rounded-full bg-red-100 px-2.5 py-1 text-xs font-medium text-red-700">
{blockingFindings.length}
</span>
</div>
{#if blockingFindings.length === 0}
<p class="mt-3 text-sm text-slate-600">
{$t.dataset_review?.findings?.blocking_empty}
</p>
{:else}
<div class="mt-3 space-y-3">
{#each blockingFindings as finding}
<article class="rounded-xl border border-white/70 bg-white/80 p-4 shadow-sm">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-slate-900">
{finding.title}
</h4>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
{finding.code}
</span>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
{getAreaLabel(finding.area)}
</span>
</div>
<p class="mt-2 text-sm leading-6 text-slate-700">
{finding.message}
</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs">
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">
{$t.dataset_review?.findings?.resolution_label}:
{getResolutionLabel(finding.resolution_state)}
</span>
{#if finding.caused_by_ref}
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">
{$t.dataset_review?.findings?.reference_label}:
{finding.caused_by_ref}
</span>
{/if}
</div>
{#if finding.resolution_note}
<p class="mt-2 text-xs text-slate-500">{finding.resolution_note}</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100"
onclick={() => jumpToFindingTarget(finding)}
>
{$t.dataset_review?.findings?.jump_action}
</button>
</div>
</div>
</article>
{/each}
</div>
{/if}
</section>
<section class="rounded-2xl border border-amber-200 bg-amber-50 p-4">
<div class="flex items-center gap-3">
<h3 class="text-sm font-semibold text-slate-900">
{$t.dataset_review?.findings?.warning_title}
</h3>
<span class="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-700">
{warningFindings.length}
</span>
</div>
{#if warningFindings.length === 0}
<p class="mt-3 text-sm text-slate-600">
{$t.dataset_review?.findings?.warning_empty}
</p>
{:else}
<div class="mt-3 space-y-3">
{#each warningFindings as finding}
<article class="rounded-xl border border-white/70 bg-white/80 p-4 shadow-sm">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-slate-900">
{finding.title}
</h4>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
{finding.code}
</span>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
{getAreaLabel(finding.area)}
</span>
</div>
<p class="mt-2 text-sm leading-6 text-slate-700">
{finding.message}
</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs">
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">
{$t.dataset_review?.findings?.resolution_label}:
{getResolutionLabel(finding.resolution_state)}
</span>
{#if finding.caused_by_ref}
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">
{$t.dataset_review?.findings?.reference_label}:
{finding.caused_by_ref}
</span>
{/if}
</div>
{#if finding.resolution_note}
<p class="mt-2 text-xs text-slate-500">{finding.resolution_note}</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100"
onclick={() => jumpToFindingTarget(finding)}
>
{$t.dataset_review?.findings?.jump_action}
</button>
</div>
</div>
</article>
{/each}
</div>
{/if}
</section>
<section class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center gap-3">
<h3 class="text-sm font-semibold text-slate-900">
{$t.dataset_review?.findings?.informational_title}
</h3>
<span class="rounded-full bg-slate-200 px-2.5 py-1 text-xs font-medium text-slate-700">
{informationalFindings.length}
</span>
</div>
{#if informationalFindings.length === 0}
<p class="mt-3 text-sm text-slate-600">
{$t.dataset_review?.findings?.informational_empty}
</p>
{:else}
<div class="mt-3 space-y-3">
{#each informationalFindings as finding}
<article class="rounded-xl border border-white/70 bg-white/80 p-4 shadow-sm">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-slate-900">
{finding.title}
</h4>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
{finding.code}
</span>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
{getAreaLabel(finding.area)}
</span>
</div>
<p class="mt-2 text-sm leading-6 text-slate-700">
{finding.message}
</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs">
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">
{$t.dataset_review?.findings?.resolution_label}:
{getResolutionLabel(finding.resolution_state)}
</span>
{#if finding.caused_by_ref}
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">
{$t.dataset_review?.findings?.reference_label}:
{finding.caused_by_ref}
</span>
{/if}
</div>
{#if finding.resolution_note}
<p class="mt-2 text-xs text-slate-500">{finding.resolution_note}</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100"
onclick={() => jumpToFindingTarget(finding)}
>
{$t.dataset_review?.findings?.jump_action}
</button>
</div>
</div>
</article>
{/each}
</div>
{/if}
</section>
</div>
{/if}
</div>
<!-- [/DEF:ValidationFindingsPanel:Component] -->

View File

@@ -0,0 +1,161 @@
/**
* @vitest-environment jsdom
*/
// [DEF:SourceIntakePanelUxTests:Module]
// @COMPLEXITY: 3
// @SEMANTICS: dataset-review, source-intake, ux-tests, validation, recovery
// @PURPOSE: Verify source intake entry paths, validation feedback, and submit payload behavior for US1.
// @LAYER: UI
// @RELATION: [VERIFIES] ->[SourceIntakePanel]
// @UX_STATE: Idle -> Default intake renders both entry paths and waits for user input.
// @UX_STATE: Validating -> Inline feedback confirms recognized links and local payload validation.
// @UX_STATE: Rejected -> Invalid source input remains local and exposes recovery guidance.
// @TEST_CONTRACT: SourceIntakePanelProps -> ObservableIntakeUX
// @TEST_SCENARIO: invalid_superset_link_shows_rejected_state -> Invalid URL input keeps submit local and shows rejection feedback.
// @TEST_SCENARIO: recognized_superset_link_submits_payload -> Recognized link path submits normalized payload.
// @TEST_SCENARIO: dataset_selection_mode_changes_cta -> Dataset selection path switches CTA and payload source kind.
// @TEST_EDGE: missing_environment -> Required environment guard blocks submit.
// @TEST_EDGE: invalid_type -> Invalid Superset URL shows recovery hint.
// @TEST_EDGE: external_fail -> Submit callback failure is rendered inline.
// @TEST_INVARIANT: intake_contract_remains_observable -> VERIFIED_BY: [invalid_superset_link_shows_rejected_state, recognized_superset_link_submits_payload, dataset_selection_mode_changes_cta]
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/svelte";
import SourceIntakePanel from "../SourceIntakePanel.svelte";
vi.mock("$lib/i18n", () => ({
t: {
subscribe: (fn) => {
fn({
common: {
choose_environment: "Choose environment",
error: "Common error",
},
dataset_review: {
source: {
eyebrow: "Source intake",
title: "Start dataset review",
description: "Paste link or provide dataset reference.",
state_idle: "Idle",
state_validating: "Validating",
state_rejected: "Rejected",
environment_label: "Environment",
environment_required: "Environment is required",
superset_link_tab: "Superset link",
superset_link_tab_hint: "Paste dashboard or explore URL",
dataset_selection_tab: "Dataset selection",
dataset_selection_tab_hint: "Enter dataset ref",
superset_link_label: "Superset link",
dataset_selection_label: "Dataset reference",
superset_link_placeholder: "https://superset.local/dashboard/10",
dataset_selection_placeholder: "public.sales",
superset_link_hint: "Paste a full Superset URL",
dataset_selection_hint: "Provide schema.dataset reference",
recognized_link_hint: "Recognized Superset link",
superset_link_required: "Superset link is required",
dataset_selection_required: "Dataset reference is required",
superset_link_invalid: "Superset link must start with http",
submit_failed: "Submit failed",
superset_link_recovery_note: "You can fix the link inline",
dataset_selection_recovery_note: "You can fix the dataset inline",
submitting: "Submitting",
submit_superset_link: "Start from link",
submit_dataset_selection: "Start from dataset",
dataset_selection_acknowledged: "Dataset selection acknowledged",
},
},
});
return () => {};
},
},
}));
describe("SourceIntakePanel UX Contract", () => {
const environments = [{ id: "env-1", name: "DEV" }];
it("invalid_superset_link_shows_rejected_state", async () => {
const onsubmit = vi.fn();
const { container } = render(SourceIntakePanel, {
environments,
onsubmit,
});
await fireEvent.change(screen.getByRole("combobox"), {
target: { value: "env-1" },
});
await fireEvent.input(screen.getByPlaceholderText("https://superset.local/dashboard/10"), {
target: { value: "not-a-url" },
});
await fireEvent.submit(container.querySelector("form"));
expect(onsubmit).not.toHaveBeenCalled();
expect(screen.getByText("Rejected")).toBeDefined();
expect(screen.getByText("Superset link must start with http")).toBeDefined();
});
it("recognized_superset_link_submits_payload", async () => {
const onsubmit = vi.fn().mockResolvedValue(undefined);
const { container } = render(SourceIntakePanel, {
environments,
selectedEnvironmentId: "env-1",
acknowledgment: "Recognized Superset link",
onsubmit,
});
await fireEvent.input(screen.getByPlaceholderText("https://superset.local/dashboard/10"), {
target: { value: "https://demo.local/superset/dashboard/42 " },
});
await fireEvent.submit(container.querySelector("form"));
expect(onsubmit).toHaveBeenCalledWith({
environment_id: "env-1",
source_input: "https://demo.local/superset/dashboard/42",
source_kind: "superset_link",
});
expect(screen.getByText("Recognized Superset link")).toBeDefined();
});
it("dataset_selection_mode_changes_cta", async () => {
const onsubmit = vi.fn().mockResolvedValue(undefined);
const { container } = render(SourceIntakePanel, {
environments,
onsubmit,
});
await fireEvent.click(
screen.getByRole("button", {
name: "Dataset selection Enter dataset ref",
}),
);
await fireEvent.change(screen.getByRole("combobox"), {
target: { value: "env-1" },
});
await fireEvent.input(screen.getByPlaceholderText("public.sales"), {
target: { value: " public.sales " },
});
await fireEvent.submit(container.querySelector("form"));
expect(onsubmit).toHaveBeenCalledWith({
environment_id: "env-1",
source_input: "public.sales",
source_kind: "dataset_selection",
});
});
it("external_fail_renders_inline_error", async () => {
const onsubmit = vi.fn().mockRejectedValue(new Error("Backend rejected source"));
const { container } = render(SourceIntakePanel, {
environments,
selectedEnvironmentId: "env-1",
onsubmit,
});
await fireEvent.input(screen.getByPlaceholderText("https://superset.local/dashboard/10"), {
target: { value: "https://demo.local/dashboard/42" },
});
await fireEvent.submit(container.querySelector("form"));
expect(screen.getByText("Backend rejected source")).toBeDefined();
});
});
// [/DEF:SourceIntakePanelUxTests:Module]

View File

@@ -0,0 +1,141 @@
/**
* @vitest-environment jsdom
*/
// [DEF:ValidationFindingsPanelUxTests:Module]
// @COMPLEXITY: 3
// @SEMANTICS: dataset-review, findings, severity, jump, ux-tests
// @PURPOSE: Verify grouped findings visibility, empty state, and remediation jump behavior for US1.
// @LAYER: UI
// @RELATION: [VERIFIES] ->[ValidationFindingsPanel]
// @UX_STATE: Blocking -> Blocking findings dominate the panel and expose remediation jumps.
// @UX_STATE: Warning -> Warning findings remain visible with explicit resolution state.
// @UX_STATE: Informational -> Informational notes stay readable without competing with blockers.
// @TEST_CONTRACT: FindingsPanelProps -> ObservableFindingsUX
// @TEST_SCENARIO: blocking_warning_info_groups_render -> Findings render in the proper severity groups with counts.
// @TEST_SCENARIO: jump_action_maps_area_to_workspace_target -> Jump action emits normalized remediation target.
// @TEST_SCENARIO: empty_findings_show_success_state -> Empty list shows ready feedback.
// @TEST_EDGE: missing_field -> Missing optional fields do not crash rendering.
// @TEST_EDGE: invalid_type -> Unknown severity falls back to informational grouping.
// @TEST_EDGE: external_fail -> Jump callback remains optional.
// @TEST_INVARIANT: findings_groups_remain_actionable -> VERIFIED_BY: [blocking_warning_info_groups_render, jump_action_maps_area_to_workspace_target, empty_findings_show_success_state]
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/svelte";
import ValidationFindingsPanel from "../ValidationFindingsPanel.svelte";
vi.mock("$lib/i18n", () => ({
t: {
subscribe: (fn) => {
fn({
dataset_review: {
findings: {
eyebrow: "Validation findings",
title: "Findings",
description: "Review validation results",
next_action_label: "Next action",
empty: "No findings",
blocking_title: "Blocking",
blocking_empty: "No blocking findings",
warning_title: "Warnings",
warning_empty: "No warning findings",
informational_title: "Info",
informational_empty: "No informational findings",
resolution_label: "Resolution",
reference_label: "Reference",
jump_action: "Jump to area",
resolution: {
open: "Open",
approved: "Approved",
},
areas: {
source_intake: "Source intake",
dataset_profile: "Dataset summary",
semantic_enrichment: "Semantics",
},
},
workspace: {
actions: {
review_documentation: "Review documentation",
import_from_superset: "Import from Superset",
},
},
},
});
return () => {};
},
},
}));
describe("ValidationFindingsPanel UX Contract", () => {
const findings = [
{
finding_id: "f-1",
severity: "blocking",
code: "REQ_ENV",
area: "source_intake",
title: "Environment required",
message: "Select environment",
resolution_state: "open",
},
{
finding_id: "f-2",
severity: "warning",
code: "PARTIAL_RECOVERY",
area: "dataset_profile",
title: "Partial recovery",
message: "Some metadata needs review",
resolution_state: "approved",
caused_by_ref: "dashboard:42",
},
{
finding_id: "f-3",
severity: "unexpected",
code: "INFO_NOTE",
area: "semantic_enrichment",
title: "Dictionary note",
message: "Trusted source used",
resolution_state: "open",
},
];
it("blocking_warning_info_groups_render", () => {
render(ValidationFindingsPanel, {
findings,
recommendedAction: "review_documentation",
});
expect(screen.getByText("Blocking")).toBeDefined();
expect(screen.getByText("Warnings")).toBeDefined();
expect(screen.getByText("Info")).toBeDefined();
expect(screen.getByText("Environment required")).toBeDefined();
expect(screen.getByText("Partial recovery")).toBeDefined();
expect(screen.getByText("Dictionary note")).toBeDefined();
expect(screen.getByText("Review documentation")).toBeDefined();
});
it("jump_action_maps_area_to_workspace_target", async () => {
const onjump = vi.fn();
render(ValidationFindingsPanel, {
findings: [findings[0]],
recommendedAction: "import_from_superset",
onjump,
});
await fireEvent.click(screen.getByRole("button", { name: "Jump to area" }));
expect(onjump).toHaveBeenCalledWith({
target: "intake",
finding: findings[0],
});
});
it("empty_findings_show_success_state", () => {
render(ValidationFindingsPanel, {
findings: [],
recommendedAction: "review_documentation",
});
expect(screen.getByText("No findings")).toBeDefined();
});
});
// [/DEF:ValidationFindingsPanelUxTests:Module]