mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
feat(dashboard): T06/T03 — drag-reorder compression pipeline editor + studio e2e (#5727)
T06: named-combos editor gains a @dnd-kit/sortable drag-to-reorder stacked pipeline backed by a pure model (compressionPipelineModel.ts: add/remove/move/update, engine->intensity invariant, never-empty). CompressionPipelineEditor.tsx replaces the inline fixed list in CompressionCombosPageClient; order persists via the existing combos endpoint (no API change). T03: adds tests/e2e/compression-studio.spec.ts (Tela A render + Play/Compare tab switch), the dedicated compression-studio e2e combo-live-studio.spec.ts did not cover. TDD: compression-pipeline-model.test.ts (11) + compression-pipeline-editor.test.tsx (4). gaps v3.8.42 — T06 + T03.
This commit is contained in:
committed by
GitHub
parent
54ec22350f
commit
7833453bdb
@@ -16,6 +16,8 @@
|
||||
|
||||
- **memory (typed decay):** add **opt-in typed memory decay** (TV6) so the conversational memory store stops accumulating stale `episodic` noise. Each injected memory now tracks an `access_count` + `last_accessed_at` (always-on, non-destructive telemetry; migration `111_memory_typed_decay`), and an **opt-in, default-off** sweep (`MEMORY_TYPED_DECAY_ENABLED`, default `false`) deletes memories that are past a **per-type TTL** and not immune. Only `episodic` decays by default (30d, env-tunable); `factual`/`procedural`/`semantic` are immune, and any memory accessed `>= 3` times earns access immunity (mirroring "guardrail/convention/decision never decay"). The decay clock re-bases on the last access, so used memories survive. Deletions reuse `deleteMemory` (SQLite + sqlite-vec + Qdrant stay in sync) and fail open; an optional periodic sweep is doubly opt-in (also needs `MEMORY_TYPED_DECAY_SWEEP_INTERVAL>0`). With the flag off nothing is ever deleted (Rule #20 spirit). New `src/lib/memory/typedDecay.ts`. Regression guard: `tests/unit/memory/typed-decay.test.ts` (15). gaps v3.8.42 — T10/TV6.
|
||||
|
||||
- **dashboard (combos):** the named-combos editor now lets you **drag to reorder** the stacked-compression pipeline instead of only editing fixed-position steps. A new pure model (`src/shared/components/compression/compressionPipelineModel.ts`) owns add/remove/move/update with the engine→intensity invariant and a never-empty guarantee, and a `@dnd-kit/sortable` editor (`CompressionPipelineEditor.tsx`, matching the sidebar reorder pattern) replaces the inline list in `CompressionCombosPageClient`. Order persists through the existing combos endpoint. Regression guards: `tests/unit/compression-pipeline-model.test.ts` (11) + `tests/unit/ui/compression-pipeline-editor.test.tsx` (4). A dedicated `tests/e2e/compression-studio.spec.ts` (Tela A render + tab switch) closes the studios e2e gap the combo-live spec did not cover. gaps v3.8.42 — T06 + T03.
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **translator/chatcore (hardening):** re-apply two defensive review-fixes that were dropped in a branch rebuild before #5661 / #5662 landed. (1) `mergeConsecutiveSameRoleContents` (OpenAI→Gemini) now shallow-copies each entry and its `parts` array instead of pushing the input reference, so the consecutive-same-role merge never mutates the caller's objects. (2) `defaultClaudeToolType` (Claude tool defaults) now passes any non-object array entry (`null` / primitive) through unchanged instead of spreading it into a fabricated `{ type: "custom", … }` tool. No behavior change on real payloads (Gemini contents are freshly built; Claude tools are always objects); both properties are now locked by regression tests in `tests/unit/translator-gemini-consecutive-role-2191.test.ts` and `tests/unit/claude-tool-type-default-2195.test.ts`.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { STACKED_PIPELINE_ENGINE_INTENSITIES } from "@/shared/validation/compressionConfigSchemas";
|
||||
import { CompressionPipelineEditor } from "@/shared/components/compression/CompressionPipelineEditor";
|
||||
import CompressionHub from "./CompressionHub";
|
||||
|
||||
type PipelineStep = { engine: string; intensity?: string };
|
||||
@@ -158,20 +159,6 @@ function NamedCombosManager() {
|
||||
if (res.ok) refresh();
|
||||
};
|
||||
|
||||
const updateStep = (index: number, patch: Partial<PipelineStep>) => {
|
||||
setPipeline((current) =>
|
||||
current.map((step, stepIndex) => {
|
||||
if (stepIndex !== index) return step;
|
||||
const next = { ...step, ...patch };
|
||||
const allowed = ENGINE_INTENSITIES[next.engine] ?? ["standard"];
|
||||
return {
|
||||
...next,
|
||||
intensity: allowed.includes(next.intensity ?? "") ? next.intensity : allowed[0],
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const togglePack = (language: string, enabled: boolean) => {
|
||||
setSelectedPacks((current) =>
|
||||
enabled
|
||||
@@ -211,51 +198,12 @@ function NamedCombosManager() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-text-main">Pipeline</h3>
|
||||
<button
|
||||
onClick={() =>
|
||||
setPipeline((current) => [...current, { engine: "caveman", intensity: "full" }])
|
||||
}
|
||||
className="rounded-lg border border-border px-3 py-1.5 text-xs text-text-main"
|
||||
>
|
||||
Add step
|
||||
</button>
|
||||
</div>
|
||||
{pipeline.map((step, index) => (
|
||||
<div key={index} className="grid grid-cols-[1fr_1fr_auto] gap-2">
|
||||
<select
|
||||
value={step.engine}
|
||||
onChange={(event) => updateStep(index, { engine: event.target.value })}
|
||||
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-main"
|
||||
>
|
||||
{Object.keys(ENGINE_INTENSITIES).map((engine) => (
|
||||
<option key={engine} value={engine}>
|
||||
{engine}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={step.intensity ?? ""}
|
||||
onChange={(event) => updateStep(index, { intensity: event.target.value })}
|
||||
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-main"
|
||||
>
|
||||
{(ENGINE_INTENSITIES[step.engine] ?? ["standard"]).map((intensity) => (
|
||||
<option key={intensity} value={intensity}>
|
||||
{intensity}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setPipeline((current) => current.filter((_, i) => i !== index))}
|
||||
className="rounded-lg border border-border px-3 py-2 text-sm text-text-main"
|
||||
disabled={pipeline.length <= 1}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-4">
|
||||
<CompressionPipelineEditor
|
||||
steps={pipeline}
|
||||
onChange={setPipeline}
|
||||
engineIntensities={ENGINE_INTENSITIES}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
|
||||
175
src/shared/components/compression/CompressionPipelineEditor.tsx
Normal file
175
src/shared/components/compression/CompressionPipelineEditor.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
// T06 — drag-to-reorder editor for the stacked-compression pipeline (gaps v3.8.42).
|
||||
//
|
||||
// A controlled component: it renders `steps` and reports every edit back through `onChange`
|
||||
// (parent owns the state + persistence). All mutations go through the pure
|
||||
// `compressionPipelineModel` so invariants (valid intensity, non-empty pipeline) hold.
|
||||
//
|
||||
// Hydration note: this lives under the combos screen, which deliberately uses NO
|
||||
// `useTranslations` (an earlier redesign failed to hydrate on the production build with a
|
||||
// page-level `useTranslations`). Strings are literal English to match `CompressionHub`.
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
allowedIntensities,
|
||||
addLayer,
|
||||
moveLayer,
|
||||
removeLayer,
|
||||
updateLayer,
|
||||
type EngineIntensities,
|
||||
type PipelineStep,
|
||||
} from "./compressionPipelineModel";
|
||||
|
||||
export type { PipelineStep } from "./compressionPipelineModel";
|
||||
|
||||
type Props = {
|
||||
steps: PipelineStep[];
|
||||
onChange: (steps: PipelineStep[]) => void;
|
||||
engineIntensities: EngineIntensities;
|
||||
};
|
||||
|
||||
const SELECT_CLASS =
|
||||
"rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-main w-full";
|
||||
|
||||
function SortableRow(props: {
|
||||
id: string;
|
||||
index: number;
|
||||
step: PipelineStep;
|
||||
engines: string[];
|
||||
engineIntensities: EngineIntensities;
|
||||
canRemove: boolean;
|
||||
onPatch: (patch: Partial<PipelineStep>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: props.id,
|
||||
});
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
data-testid={`pipeline-row-${props.index}`}
|
||||
className="grid grid-cols-[auto_1fr_1fr_auto] items-center gap-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Drag to reorder step"
|
||||
data-testid={`pipeline-drag-${props.index}`}
|
||||
className="cursor-grab rounded-lg border border-border px-2 py-2 text-sm text-text-muted"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
⠿
|
||||
</button>
|
||||
<select
|
||||
aria-label="Engine"
|
||||
value={props.step.engine}
|
||||
onChange={(event) => props.onPatch({ engine: event.target.value })}
|
||||
className={SELECT_CLASS}
|
||||
>
|
||||
{props.engines.map((engine) => (
|
||||
<option key={engine} value={engine}>
|
||||
{engine}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Intensity"
|
||||
value={props.step.intensity ?? ""}
|
||||
onChange={(event) => props.onPatch({ intensity: event.target.value })}
|
||||
className={SELECT_CLASS}
|
||||
>
|
||||
{allowedIntensities(props.step.engine, props.engineIntensities).map((intensity) => (
|
||||
<option key={intensity} value={intensity}>
|
||||
{intensity}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onRemove}
|
||||
disabled={!props.canRemove}
|
||||
data-testid={`pipeline-remove-${props.index}`}
|
||||
className="rounded-lg border border-border px-3 py-2 text-sm text-text-main disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompressionPipelineEditor({ steps, onChange, engineIntensities }: Props) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
// Index-based stable ids: a controlled list with no per-row id. Ids are stable within a
|
||||
// render (0..n-1); reorder maps id→index before delegating to the pure model.
|
||||
const ids = steps.map((_, index) => String(index));
|
||||
const engines = Object.keys(engineIntensities);
|
||||
const firstEngine = engines[0] ?? "rtk";
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const from = ids.indexOf(String(active.id));
|
||||
const to = ids.indexOf(String(over.id));
|
||||
onChange(moveLayer(steps, from, to));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid="compression-pipeline-editor">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-text-main">Pipeline</h3>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pipeline-add-step"
|
||||
onClick={() => onChange(addLayer(steps, { engine: firstEngine }, engineIntensities))}
|
||||
className="rounded-lg border border-border px-3 py-1.5 text-xs text-text-main"
|
||||
>
|
||||
Add step
|
||||
</button>
|
||||
</div>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, index) => (
|
||||
<SortableRow
|
||||
key={ids[index]}
|
||||
id={ids[index]}
|
||||
index={index}
|
||||
step={step}
|
||||
engines={engines}
|
||||
engineIntensities={engineIntensities}
|
||||
canRemove={steps.length > 1}
|
||||
onPatch={(patch) => onChange(updateLayer(steps, index, patch, engineIntensities))}
|
||||
onRemove={() => onChange(removeLayer(steps, index))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Pure model for the stacked-compression pipeline editor (T06 — gaps v3.8.42).
|
||||
*
|
||||
* Every operation returns a NEW `PipelineStep[]` (never mutates the input), preserves the
|
||||
* engine→intensity invariant (a step's intensity is always one its engine allows), and keeps
|
||||
* the pipeline non-empty — so the combos editor can never persist an invalid stacked
|
||||
* pipeline that the `PUT /api/context/combos/[id]` route would reject. The React editor is a
|
||||
* thin shell over these functions; the logic is tested in isolation.
|
||||
*/
|
||||
|
||||
export type PipelineStep = { engine: string; intensity?: string };
|
||||
export type EngineIntensities = Record<string, readonly string[]>;
|
||||
|
||||
const FALLBACK_INTENSITIES: readonly string[] = ["standard"];
|
||||
|
||||
/** Intensities a given engine allows (falls back to `["standard"]` for unknown engines). */
|
||||
export function allowedIntensities(
|
||||
engine: string,
|
||||
table: EngineIntensities
|
||||
): readonly string[] {
|
||||
const list = table[engine];
|
||||
return list && list.length > 0 ? list : FALLBACK_INTENSITIES;
|
||||
}
|
||||
|
||||
/** Coerce a step's intensity to one valid for its engine (first allowed when invalid). */
|
||||
export function normalizeStep(step: PipelineStep, table: EngineIntensities): PipelineStep {
|
||||
const allowed = allowedIntensities(step.engine, table);
|
||||
return {
|
||||
engine: step.engine,
|
||||
intensity: allowed.includes(step.intensity ?? "") ? step.intensity : allowed[0],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder: move the step at `from` to `to`. Out-of-range indices (or `from === to`) return a
|
||||
* copy unchanged. The result is always a permutation of the input (same length, same members).
|
||||
*/
|
||||
export function moveLayer(steps: PipelineStep[], from: number, to: number): PipelineStep[] {
|
||||
const next = steps.slice();
|
||||
if (from === to) return next;
|
||||
if (from < 0 || from >= steps.length || to < 0 || to >= steps.length) return next;
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Append a new layer (normalized for its engine). */
|
||||
export function addLayer(
|
||||
steps: PipelineStep[],
|
||||
step: PipelineStep,
|
||||
table: EngineIntensities
|
||||
): PipelineStep[] {
|
||||
return [...steps, normalizeStep(step, table)];
|
||||
}
|
||||
|
||||
/** Remove the layer at `index`, never dropping below `minLength` (default 1). */
|
||||
export function removeLayer(
|
||||
steps: PipelineStep[],
|
||||
index: number,
|
||||
minLength = 1
|
||||
): PipelineStep[] {
|
||||
if (steps.length <= minLength) return steps.slice();
|
||||
if (index < 0 || index >= steps.length) return steps.slice();
|
||||
return steps.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
/** Patch the layer at `index`, re-normalizing intensity for the (possibly new) engine. */
|
||||
export function updateLayer(
|
||||
steps: PipelineStep[],
|
||||
index: number,
|
||||
patch: Partial<PipelineStep>,
|
||||
table: EngineIntensities
|
||||
): PipelineStep[] {
|
||||
if (index < 0 || index >= steps.length) return steps.slice();
|
||||
return steps.map((step, i) =>
|
||||
i === index ? normalizeStep({ ...step, ...patch }, table) : step
|
||||
);
|
||||
}
|
||||
50
tests/e2e/compression-studio.spec.ts
Normal file
50
tests/e2e/compression-studio.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
/**
|
||||
* T03 — Compression Studio (Tela A) smoke e2e (gaps v3.8.42).
|
||||
*
|
||||
* The studio's reducers/renderers (compressionFlowModel, WaterfallInspector,
|
||||
* CompressionCockpit, PlayView/CompareView) are unit/vitest-covered. What unit tests
|
||||
* CANNOT catch is a client-side render / hydration crash on the real page — the same
|
||||
* "no `useTranslations` trap" risk the compression-UI plan flagged. The existing
|
||||
* combo-live-studio spec guards `/dashboard/combos/live`; this is its missing
|
||||
* counterpart for the dedicated compression studio (Tela A), which that spec does not
|
||||
* touch.
|
||||
*
|
||||
* It loads /dashboard/compression/studio and asserts the studio mounts (Play tab + its
|
||||
* lane), then flips to the Compare tab and asserts the switch took effect. The
|
||||
* visibility assertions ARE the hydration-trap guard: a render-time crash would mean
|
||||
* none of these testids ever appear.
|
||||
*
|
||||
* Out of scope (kept unit-covered, to stay non-flaky): driving a live WS compression
|
||||
* cascade (needs `compression.step` events an e2e cannot inject) and asserting console
|
||||
* output (dev-mode on-demand compilation emits transient fast-refresh noise).
|
||||
*/
|
||||
test.describe("Compression Studio (Tela A)", () => {
|
||||
test("loads /dashboard/compression/studio and renders the Play lane without crashing", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/compression/studio");
|
||||
|
||||
const playTab = page.locator('[data-testid="tab-play"]');
|
||||
await expect(playTab).toBeVisible({ timeout: 30_000 });
|
||||
// Play view is the default tab → its lane proves the studio body mounted.
|
||||
await expect(page.locator('[data-testid="play-lane"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("switches from Play to Compare", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/compression/studio");
|
||||
|
||||
const compareTab = page.locator('[data-testid="tab-compare"]');
|
||||
await expect(compareTab).toBeVisible({ timeout: 30_000 });
|
||||
await compareTab.click();
|
||||
await expect(compareTab).toHaveAttribute("aria-pressed", "true");
|
||||
// Compare view mounted → its load control is present.
|
||||
await expect(page.locator('[data-testid="compare-load"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
111
tests/unit/compression-pipeline-model.test.ts
Normal file
111
tests/unit/compression-pipeline-model.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
allowedIntensities,
|
||||
normalizeStep,
|
||||
moveLayer,
|
||||
addLayer,
|
||||
removeLayer,
|
||||
updateLayer,
|
||||
type EngineIntensities,
|
||||
type PipelineStep,
|
||||
} from "../../src/shared/components/compression/compressionPipelineModel.ts";
|
||||
|
||||
// T06 — pure pipeline model. Reorder/add/remove/update preserve invariants.
|
||||
|
||||
const TABLE: EngineIntensities = {
|
||||
rtk: ["standard", "aggressive"],
|
||||
caveman: ["lite", "full", "ultra"],
|
||||
llmlingua: ["standard"],
|
||||
};
|
||||
|
||||
const BASE: PipelineStep[] = [
|
||||
{ engine: "rtk", intensity: "standard" },
|
||||
{ engine: "caveman", intensity: "full" },
|
||||
{ engine: "llmlingua", intensity: "standard" },
|
||||
];
|
||||
|
||||
describe("compressionPipelineModel — normalize", () => {
|
||||
it("keeps a valid intensity and coerces an invalid one to the first allowed", () => {
|
||||
assert.deepEqual(normalizeStep({ engine: "caveman", intensity: "ultra" }, TABLE), {
|
||||
engine: "caveman",
|
||||
intensity: "ultra",
|
||||
});
|
||||
assert.deepEqual(normalizeStep({ engine: "caveman", intensity: "bogus" }, TABLE), {
|
||||
engine: "caveman",
|
||||
intensity: "lite",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to standard for an unknown engine", () => {
|
||||
assert.deepEqual(allowedIntensities("nope", TABLE), ["standard"]);
|
||||
assert.deepEqual(normalizeStep({ engine: "nope" }, TABLE), {
|
||||
engine: "nope",
|
||||
intensity: "standard",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("compressionPipelineModel — moveLayer", () => {
|
||||
it("moves a step and is always a permutation (same length + members)", () => {
|
||||
const moved = moveLayer(BASE, 0, 2);
|
||||
assert.deepEqual(
|
||||
moved.map((s) => s.engine),
|
||||
["caveman", "llmlingua", "rtk"]
|
||||
);
|
||||
assert.equal(moved.length, BASE.length);
|
||||
});
|
||||
|
||||
it("does not mutate the input", () => {
|
||||
const copy = BASE.map((s) => ({ ...s }));
|
||||
moveLayer(BASE, 0, 2);
|
||||
assert.deepEqual(BASE, copy);
|
||||
});
|
||||
|
||||
it("returns an unchanged copy for out-of-range or no-op moves", () => {
|
||||
assert.deepEqual(moveLayer(BASE, 1, 1), BASE);
|
||||
assert.deepEqual(moveLayer(BASE, -1, 2), BASE);
|
||||
assert.deepEqual(moveLayer(BASE, 0, 9), BASE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compressionPipelineModel — add/remove", () => {
|
||||
it("addLayer appends a normalized step", () => {
|
||||
const next = addLayer(BASE, { engine: "caveman", intensity: "bogus" }, TABLE);
|
||||
assert.equal(next.length, 4);
|
||||
assert.deepEqual(next[3], { engine: "caveman", intensity: "lite" });
|
||||
});
|
||||
|
||||
it("removeLayer drops the indexed step but never goes below minLength", () => {
|
||||
const next = removeLayer(BASE, 1);
|
||||
assert.deepEqual(
|
||||
next.map((s) => s.engine),
|
||||
["rtk", "llmlingua"]
|
||||
);
|
||||
const single: PipelineStep[] = [{ engine: "rtk", intensity: "standard" }];
|
||||
assert.deepEqual(removeLayer(single, 0), single, "must not remove the last step");
|
||||
});
|
||||
|
||||
it("removeLayer ignores out-of-range index", () => {
|
||||
assert.deepEqual(removeLayer(BASE, 9), BASE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compressionPipelineModel — updateLayer", () => {
|
||||
it("patches a step and re-normalizes intensity for a new engine", () => {
|
||||
const next = updateLayer(BASE, 0, { engine: "caveman" }, TABLE);
|
||||
// rtk(standard) → caveman: 'standard' is not a caveman intensity → first allowed (lite)
|
||||
assert.deepEqual(next[0], { engine: "caveman", intensity: "lite" });
|
||||
// other steps untouched
|
||||
assert.deepEqual(next[1], BASE[1]);
|
||||
});
|
||||
|
||||
it("keeps a still-valid intensity when only the engine changes to a compatible one", () => {
|
||||
const next = updateLayer(BASE, 1, { intensity: "ultra" }, TABLE);
|
||||
assert.deepEqual(next[1], { engine: "caveman", intensity: "ultra" });
|
||||
});
|
||||
|
||||
it("ignores out-of-range index", () => {
|
||||
assert.deepEqual(updateLayer(BASE, 9, { engine: "rtk" }, TABLE), BASE);
|
||||
});
|
||||
});
|
||||
119
tests/unit/ui/compression-pipeline-editor.test.tsx
Normal file
119
tests/unit/ui/compression-pipeline-editor.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// T06 — CompressionPipelineEditor (gaps v3.8.42). The drag-reorder logic itself lives in
|
||||
// the pure `compressionPipelineModel` (covered by compression-pipeline-model.test.ts); here
|
||||
// we assert the controlled-component wiring: rendering one row per step and reporting
|
||||
// add/remove/patch edits through `onChange`.
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const { CompressionPipelineEditor } = await import(
|
||||
"../../../src/shared/components/compression/CompressionPipelineEditor"
|
||||
);
|
||||
|
||||
const TABLE = {
|
||||
rtk: ["standard", "aggressive"],
|
||||
caveman: ["lite", "full", "ultra"],
|
||||
} as const;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function render(steps: { engine: string; intensity?: string }[], onChange: (s: unknown) => void) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<CompressionPipelineEditor
|
||||
steps={steps}
|
||||
onChange={onChange}
|
||||
engineIntensities={TABLE as unknown as Record<string, readonly string[]>}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("CompressionPipelineEditor (T06)", () => {
|
||||
it("renders one sortable row per step", () => {
|
||||
render(
|
||||
[
|
||||
{ engine: "rtk", intensity: "standard" },
|
||||
{ engine: "caveman", intensity: "full" },
|
||||
],
|
||||
() => {}
|
||||
);
|
||||
expect(container.querySelector('[data-testid="compression-pipeline-editor"]')).toBeTruthy();
|
||||
expect(container.querySelectorAll('[data-testid^="pipeline-row-"]').length).toBe(2);
|
||||
// each row exposes a drag handle
|
||||
expect(container.querySelectorAll('[data-testid^="pipeline-drag-"]').length).toBe(2);
|
||||
});
|
||||
|
||||
it("Add step appends a normalized step via onChange", () => {
|
||||
let received: { engine: string; intensity?: string }[] | null = null;
|
||||
render([{ engine: "rtk", intensity: "standard" }], (s) => {
|
||||
received = s as typeof received;
|
||||
});
|
||||
const addBtn = container.querySelector('[data-testid="pipeline-add-step"]') as HTMLButtonElement;
|
||||
act(() => addBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(received).not.toBeNull();
|
||||
expect(received!.length).toBe(2);
|
||||
expect(received![1].engine).toBe("rtk");
|
||||
// appended step is normalized (valid intensity for the engine)
|
||||
expect(TABLE.rtk).toContain(received![1].intensity);
|
||||
});
|
||||
|
||||
it("Remove drops the row; the button is disabled when only one step remains", () => {
|
||||
let received: unknown[] | null = null;
|
||||
render(
|
||||
[
|
||||
{ engine: "rtk", intensity: "standard" },
|
||||
{ engine: "caveman", intensity: "full" },
|
||||
],
|
||||
(s) => {
|
||||
received = s as unknown[];
|
||||
}
|
||||
);
|
||||
const removeBtn = container.querySelector(
|
||||
'[data-testid="pipeline-remove-0"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(removeBtn.disabled).toBe(false);
|
||||
act(() => removeBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(received).toEqual([{ engine: "caveman", intensity: "full" }]);
|
||||
|
||||
// single-step pipeline: remove is disabled (never below minLength 1)
|
||||
render([{ engine: "rtk", intensity: "standard" }], () => {});
|
||||
const onlyRemove = container.querySelector(
|
||||
'[data-testid="pipeline-remove-0"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(onlyRemove.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("changing the engine re-normalizes the intensity through onChange", () => {
|
||||
let received: { engine: string; intensity?: string }[] | null = null;
|
||||
render([{ engine: "rtk", intensity: "aggressive" }], (s) => {
|
||||
received = s as typeof received;
|
||||
});
|
||||
const engineSelect = container.querySelector(
|
||||
'[data-testid="pipeline-row-0"] select[aria-label="Engine"]'
|
||||
) as HTMLSelectElement;
|
||||
act(() => {
|
||||
engineSelect.value = "caveman";
|
||||
engineSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(received).not.toBeNull();
|
||||
// 'aggressive' is not a caveman intensity → coerced to the first caveman intensity
|
||||
expect(received![0].engine).toBe("caveman");
|
||||
expect(TABLE.caveman).toContain(received![0].intensity);
|
||||
expect(received![0].intensity).toBe("lite");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user