mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-29 02:22:10 +03:00
feat(combo): choose sort method for combo models (manual/provider/score/name) (#11812)
Lets the combo dashboard builder order models manually/by-provider/by-score/by-name — the choice is stored in config.modelSort and re-applied on load and after adding models. Score-based ordering fetches provider rankings from the existing /api/free-provider-rankings endpoint; the field is inert on execution (client-side hint only). 9/9 focused tests passing (schema, sort logic, and rendered component). Thanks!
This commit is contained in:
1
changelog.d/features/11812-combo-sort-methods.md
Normal file
1
changelog.d/features/11812-combo-sort-methods.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(combo):** choose how combo models are ordered — manual, provider, score, or name — via a sort control in the dashboard builder, persisted in `config.modelSort` and re-applied on load and after add ([#11812](https://github.com/diegosouzapw/OmniRoute/pull/11812)) — thanks @maxmad64bis
|
||||
60
src/app/(dashboard)/dashboard/combos/ComboSortSelect.tsx
Normal file
60
src/app/(dashboard)/dashboard/combos/ComboSortSelect.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
import type { SortMethod } from "@/lib/combos/comboSort";
|
||||
|
||||
function getI18nOrFallback(
|
||||
t: ((k: string, f: string) => string) & { has?: (k: string) => boolean },
|
||||
key: string,
|
||||
fallback: string
|
||||
): string {
|
||||
try {
|
||||
if (typeof t.has === "function" && !t.has(key)) return fallback;
|
||||
} catch {}
|
||||
const out = t(key, fallback);
|
||||
return typeof out === "string" && out.length > 0 ? out : fallback;
|
||||
}
|
||||
|
||||
const OPTIONS: { value: SortMethod; key: string; fallback: string }[] = [
|
||||
{ value: "manual", key: "combo.sort.method.manual", fallback: "Manual" },
|
||||
{ value: "provider", key: "combo.sort.method.provider", fallback: "Provider" },
|
||||
{ value: "score", key: "combo.sort.method.score", fallback: "Score" },
|
||||
{ value: "name", key: "combo.sort.method.name", fallback: "Name" },
|
||||
];
|
||||
|
||||
export function ComboSortSelect({
|
||||
value,
|
||||
onChange,
|
||||
t,
|
||||
}: {
|
||||
value: SortMethod;
|
||||
onChange: (m: SortMethod) => void;
|
||||
t: ((k: string, f: string) => string) & { has?: (k: string) => boolean };
|
||||
}) {
|
||||
const scoreHint = getI18nOrFallback(
|
||||
t,
|
||||
"combo.sort.scoreHint",
|
||||
"Score ranking applies to free providers only; others stay in place."
|
||||
);
|
||||
const isScore = value === "score";
|
||||
return (
|
||||
<label>
|
||||
{getI18nOrFallback(t, "combo.sort.label", "Sort by")}
|
||||
<select
|
||||
aria-label={getI18nOrFallback(t, "combo.sort.label", "Sort by")}
|
||||
{...(isScore ? { "aria-describedby": "combo-sort-score-hint", title: scoreHint } : {})}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as SortMethod)}
|
||||
>
|
||||
{OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{getI18nOrFallback(t, o.key, o.fallback)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isScore ? (
|
||||
<p id="combo-sort-score-hint" className="mt-1 text-[10px] text-text-muted">
|
||||
{scoreHint}
|
||||
</p>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,16 @@ import KimiComboPresetCard from "./KimiComboPresetCard";
|
||||
import { KIMI_CODING_PRESET, hasKimiCodingPreset } from "./kimiComboPreset";
|
||||
import BuilderIntelligentStep from "./BuilderIntelligentStep";
|
||||
import IntelligentComboPanel from "./IntelligentComboPanel";
|
||||
import { ComboSortSelect } from "./ComboSortSelect";
|
||||
import {
|
||||
sortComboStepsSync,
|
||||
sortComboStepsByScore,
|
||||
fetchProviderRankings,
|
||||
normalizeSortMethod,
|
||||
isValidSortMethod,
|
||||
type SortMethod,
|
||||
} from "@/lib/combos/comboSort";
|
||||
import type { ComboStep } from "@/lib/combos/steps";
|
||||
import {
|
||||
filterCombosByStrategyCategory,
|
||||
getStrategyCategory,
|
||||
@@ -2026,6 +2036,25 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
const [builderStage, setBuilderStage] = useState<string>(COMBO_BUILDER_STAGES[0]);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [config, setConfig] = useState(sanitizeComboRuntimeConfig(combo?.config));
|
||||
// Validate persisted enum; ensure reset on combo change not just first mount.
|
||||
const initialSortMethod = normalizeSortMethod(config.modelSort?.method);
|
||||
const [sortMethod, setSortMethod] = useState<SortMethod>(initialSortMethod);
|
||||
useEffect(() => {
|
||||
// Sync point: when the combo identity changes, re-derive sort method.
|
||||
// Manual edits via handleSortChange already set sortMethod inside resetFormForCombo,
|
||||
// but this guards the case where the modal is reused (edit-A→close→edit-B without unmount).
|
||||
setSortMethod(normalizeSortMethod(combo?.config?.modelSort?.method));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [combo?.id]);
|
||||
const modelsRef = useRef(models);
|
||||
const sortMethodRef = useRef<SortMethod>(sortMethod);
|
||||
const resetSortGenerationRef = useRef(0);
|
||||
useEffect(() => {
|
||||
modelsRef.current = models;
|
||||
}, [models]);
|
||||
useEffect(() => {
|
||||
sortMethodRef.current = sortMethod;
|
||||
}, [sortMethod]);
|
||||
const [showStrategyNudge, setShowStrategyNudge] = useState(false);
|
||||
const strategyChangeMountedRef = useRef(false);
|
||||
// Agent features (#399 / #401 / #454)
|
||||
@@ -2060,9 +2089,34 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
Object.fromEntries(Object.entries(nextDefaults).filter(([key]) => key !== "strategy"))
|
||||
);
|
||||
|
||||
// Validate persisted enum; tolerate hand-edited DB values.
|
||||
const loadedMethod = normalizeSortMethod(nextCombo?.config?.modelSort?.method);
|
||||
// Generation guard so a stale score fetch can't clobber the next combo.
|
||||
const myGen = ++resetSortGenerationRef.current;
|
||||
setSortMethod(loadedMethod);
|
||||
sortMethodRef.current = loadedMethod;
|
||||
setName(nextCombo?.name || "");
|
||||
setDescription(nextCombo?.description || "");
|
||||
setModels((nextCombo?.models || []).map((m) => normalizeModelEntry(m)));
|
||||
// Branch so only one setModels runs (no raw-then-sorted double set).
|
||||
// Score branch is async; guard with generation + cancelled from the caller's effect.
|
||||
if (loadedMethod === "manual") {
|
||||
setModels((nextCombo?.models || []).map((m) => normalizeModelEntry(m)));
|
||||
} else if (loadedMethod === "score") {
|
||||
const base = (nextCombo?.models || []).map((mm) => normalizeModelEntry(mm)) as ComboStep[];
|
||||
fetchProviderRankings()
|
||||
.then((rk) => sortComboStepsByScore(base, rk))
|
||||
.then((sorted) => {
|
||||
if (resetSortGenerationRef.current !== myGen) return;
|
||||
setModels(sorted as typeof base);
|
||||
})
|
||||
.catch(() => {
|
||||
if (resetSortGenerationRef.current !== myGen) return;
|
||||
setModels(base);
|
||||
});
|
||||
} else {
|
||||
const base = (nextCombo?.models || []).map((mm) => normalizeModelEntry(mm)) as ComboStep[];
|
||||
setModels(sortComboStepsSync(base, loadedMethod));
|
||||
}
|
||||
setStrategy(nextCombo?.strategy || comboDefaults?.strategy || "priority");
|
||||
setConfig(nextConfig);
|
||||
setShowAdvanced(isExpertMode);
|
||||
@@ -2597,7 +2651,10 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
setBuilderError("");
|
||||
};
|
||||
|
||||
const handleAddModel = (model) => {
|
||||
const handleAddModel = async (model) => {
|
||||
// Use refs to avoid stale closure when awaiting a score fetch.
|
||||
const currentModels = (modelsRef.current ?? models) as typeof models;
|
||||
const currentMethod = sortMethodRef.current;
|
||||
const qualifiedModel = typeof model?.value === "string" ? model.value : "";
|
||||
const parsedModel = parseQualifiedModel(qualifiedModel);
|
||||
const resolvedProviderId =
|
||||
@@ -2611,7 +2668,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
...(resolvedProviderId ? { providerId: resolvedProviderId } : {}),
|
||||
weight: 0,
|
||||
};
|
||||
if (hasExactModelStepDuplicate(models, nextEntry)) {
|
||||
if (hasExactModelStepDuplicate(currentModels, nextEntry)) {
|
||||
setBuilderError(
|
||||
getI18nOrFallback(
|
||||
t,
|
||||
@@ -2621,7 +2678,21 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
);
|
||||
return;
|
||||
}
|
||||
setModels([...models, nextEntry]);
|
||||
const added = [...currentModels, nextEntry];
|
||||
if (currentMethod === "manual") {
|
||||
setModels(added);
|
||||
} else if (currentMethod === "score") {
|
||||
try {
|
||||
const rankings = await fetchProviderRankings();
|
||||
// Single-user modal; rapid double-add while fetch is in flight is low-probability.
|
||||
const sorted = await sortComboStepsByScore(added, rankings);
|
||||
setModels(sorted);
|
||||
} catch {
|
||||
setModels(added);
|
||||
}
|
||||
} else {
|
||||
setModels(sortComboStepsSync(added, currentMethod as "provider" | "name"));
|
||||
}
|
||||
setBuilderError("");
|
||||
};
|
||||
|
||||
@@ -2649,10 +2720,27 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
// would each close over the same stale `models` snapshot and keep only the
|
||||
// last entry. Extracted so tests exercise this real implementation instead
|
||||
// of a hand-maintained mirror (#8526).
|
||||
const handleAddModels = (selected) => {
|
||||
const { next, addedAny } = computeBatchAddModelSteps(models, selected, builderProviders);
|
||||
const handleAddModels = async (selected) => {
|
||||
// Same ref discipline as handleAddModel — don't rely on closed-over render snapshot.
|
||||
const currentModels = (modelsRef.current ?? models) as typeof models;
|
||||
const currentMethod = sortMethodRef.current;
|
||||
const { next, addedAny } = computeBatchAddModelSteps(currentModels, selected, builderProviders);
|
||||
if (!addedAny) return;
|
||||
setModels(next);
|
||||
if (currentMethod === "manual") {
|
||||
setModels(next);
|
||||
} else if (currentMethod === "score") {
|
||||
try {
|
||||
const rankings = await fetchProviderRankings();
|
||||
// Functional note: `next` is the post-batch snapshot. Concurrent single-add
|
||||
// racing this batch is low-probability single-user; last write wins.
|
||||
const sorted = await sortComboStepsByScore(next, rankings);
|
||||
setModels(sorted);
|
||||
} catch {
|
||||
setModels(next);
|
||||
}
|
||||
} else {
|
||||
setModels(sortComboStepsSync(next, currentMethod as "provider" | "name"));
|
||||
}
|
||||
setBuilderError("");
|
||||
};
|
||||
|
||||
@@ -2785,6 +2873,30 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
setModels(newModels);
|
||||
};
|
||||
|
||||
const handleSortChange = async (next: SortMethod) => {
|
||||
if (!isValidSortMethod(next)) return;
|
||||
setSortMethod(next);
|
||||
sortMethodRef.current = next;
|
||||
setConfig((prev) => ({ ...prev, modelSort: { method: next } }));
|
||||
if (next === "manual") return;
|
||||
if (next === "score") {
|
||||
try {
|
||||
const rankings = await fetchProviderRankings();
|
||||
// Capture snapshot; if a concurrent add lands while rankings fetch
|
||||
// is in flight, modelsRef has the freshest value — prefer it at
|
||||
// sort time. Single-user UI, low-probability race; fallback keeps
|
||||
// previous models if the rankings fetch fails (mirrors load path).
|
||||
const snapshot = (modelsRef.current ?? models) as ComboStep[];
|
||||
const sorted = await sortComboStepsByScore(snapshot, rankings);
|
||||
setModels(sorted as typeof models);
|
||||
} catch {
|
||||
// Keep previous models; same silent-fallback precedent as load path.
|
||||
}
|
||||
return;
|
||||
}
|
||||
setModels((prev) => sortComboStepsSync(prev as ComboStep[], next) as typeof prev);
|
||||
};
|
||||
|
||||
// Drag and Drop handlers
|
||||
const handleDragStart = (e, index) => {
|
||||
setDragIndex(index);
|
||||
@@ -3484,6 +3596,10 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<ComboSortSelect value={sortMethod} onChange={handleSortChange} t={t} />
|
||||
</div>
|
||||
|
||||
{models.length === 0 ? (
|
||||
<div className="text-center py-4 border border-dashed border-black/10 dark:border-white/10 rounded-lg bg-black/[0.01] dark:bg-white/[0.01]">
|
||||
<span className="material-symbols-outlined text-text-muted text-xl mb-1">
|
||||
|
||||
@@ -12918,6 +12918,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"combo": {
|
||||
"sort": {
|
||||
"label": "Sort by",
|
||||
"method": {
|
||||
"manual": "Manual",
|
||||
"provider": "Provider",
|
||||
"score": "Score (free models)",
|
||||
"name": "Name"
|
||||
},
|
||||
"scoreHint": "Score ranking applies to free providers only; others stay in place."
|
||||
}
|
||||
},
|
||||
"comboControl": {
|
||||
"title": "Combo Control Center",
|
||||
"unavailable": "Combo Control Center unavailable",
|
||||
|
||||
118
src/lib/combos/comboSort.ts
Normal file
118
src/lib/combos/comboSort.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
// src/lib/combos/comboSort.ts
|
||||
import { OAUTH_PROVIDERS, NOAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/providers";
|
||||
import type { ComboStep } from "@/lib/combos/steps";
|
||||
|
||||
export type { ComboStep };
|
||||
|
||||
export type SortMethod = "manual" | "provider" | "score" | "name";
|
||||
|
||||
export const SORT_METHODS: readonly SortMethod[] = ["manual", "provider", "score", "name"] as const;
|
||||
const VALID_SORT_METHODS = new Set<string>(SORT_METHODS as readonly string[]);
|
||||
|
||||
export function normalizeSortMethod(raw: unknown): SortMethod {
|
||||
return VALID_SORT_METHODS.has(raw as string) ? (raw as SortMethod) : "manual";
|
||||
}
|
||||
|
||||
export function isValidSortMethod(raw: unknown): raw is SortMethod {
|
||||
return VALID_SORT_METHODS.has(raw as string);
|
||||
}
|
||||
|
||||
// Mirrors CANONICAL_PROVIDER_ORDER from src/app/api/v1/models/catalogOrder.ts — keep in sync.
|
||||
// Both are derived from OAUTH+NOAUTH+APIKEY keys; drift would silently diverge catalog vs combo order.
|
||||
export const PROVIDER_ORDER: readonly string[] = [
|
||||
...Object.keys(OAUTH_PROVIDERS),
|
||||
...Object.keys(NOAUTH_PROVIDERS),
|
||||
...Object.keys(APIKEY_PROVIDERS),
|
||||
];
|
||||
|
||||
const REFERENCE_SENTINEL = " combo-ref"; // sorts after any real provider id
|
||||
|
||||
function providerKey(step: ComboStep): string {
|
||||
if (step.kind === "model" || step.kind === "provider-wildcard") {
|
||||
return step.providerId ?? REFERENCE_SENTINEL;
|
||||
}
|
||||
return REFERENCE_SENTINEL; // combo-ref: no providerId
|
||||
}
|
||||
|
||||
function nameKey(step: ComboStep): string {
|
||||
if (step.kind === "model") return step.model;
|
||||
if (step.kind === "provider-wildcard") return `${step.providerId}/${step.modelPattern}`;
|
||||
return step.comboName; // combo-ref
|
||||
}
|
||||
|
||||
/** Stable index for provider ordering; unknown providers go after the known list. */
|
||||
function providerRank(providerId: string): number {
|
||||
const idx = PROVIDER_ORDER.indexOf(providerId);
|
||||
return idx === -1 ? PROVIDER_ORDER.length : idx;
|
||||
}
|
||||
|
||||
/** Synchronous sorts: manual (noop), provider, name. Stable. */
|
||||
export function sortComboStepsSync(
|
||||
steps: ComboStep[],
|
||||
method: "manual" | "provider" | "name"
|
||||
): ComboStep[] {
|
||||
if (method === "manual") return steps;
|
||||
const indexed = steps.map((step, i) => ({ step, i }));
|
||||
indexed.sort((a, b) => {
|
||||
if (method === "provider") {
|
||||
const ra = providerRank(providerKey(a.step));
|
||||
const rb = providerRank(providerKey(b.step));
|
||||
if (ra !== rb) return ra - rb;
|
||||
} else {
|
||||
const na = nameKey(a.step);
|
||||
const nb = nameKey(b.step);
|
||||
if (na !== nb) return na < nb ? -1 : 1;
|
||||
}
|
||||
return a.i - b.i; // stable tiebreak preserves original order
|
||||
});
|
||||
return indexed.map((x) => x.step);
|
||||
}
|
||||
|
||||
export type Rankings = Map<string, number> | Record<string, number>;
|
||||
|
||||
function toMap(rankings: Rankings): Map<string, number> {
|
||||
return rankings instanceof Map ? rankings : new Map(Object.entries(rankings));
|
||||
}
|
||||
|
||||
/** Steps with a ranking sort descending by score; steps without a score stay
|
||||
* stable at the end (including combo-ref, which has no providerId). */
|
||||
export async function sortComboStepsByScore(
|
||||
steps: ComboStep[],
|
||||
rankings: Rankings
|
||||
): Promise<ComboStep[]> {
|
||||
const map = toMap(rankings);
|
||||
const indexed = steps.map((step, i) => {
|
||||
const pid =
|
||||
step.kind === "model" || step.kind === "provider-wildcard" ? step.providerId : undefined;
|
||||
const score = pid ? map.get(pid) : undefined;
|
||||
return { step, i, score: score ?? -1 };
|
||||
});
|
||||
indexed.sort((a, b) => {
|
||||
if (a.score !== b.score) return b.score - a.score; // desc, -1 (unscored) last
|
||||
return a.i - b.i;
|
||||
});
|
||||
return indexed.map((x) => x.step);
|
||||
}
|
||||
|
||||
/** Client-side rankings source for the dashboard (provider-level averageScore). */
|
||||
export async function fetchProviderRankings(): Promise<Map<string, number>> {
|
||||
const res = await fetch("/api/free-provider-rankings");
|
||||
if (!res.ok) throw new Error(`free-provider-rankings ${res.status}`);
|
||||
const data = (await res.json()) as { rankings: Array<{ id: string; averageScore: number }> };
|
||||
return new Map(data.rankings.map((r) => [r.id, r.averageScore]));
|
||||
}
|
||||
|
||||
/** Re-apply the current method to a models array. Sync for manual/provider/name,
|
||||
* async for score (fetches rankings when getRankings is provided). */
|
||||
export async function reapplyCurrentSort(
|
||||
steps: ComboStep[],
|
||||
method: SortMethod,
|
||||
getRankings?: () => Promise<Rankings>
|
||||
): Promise<ComboStep[]> {
|
||||
if (method === "manual") return steps;
|
||||
if (method === "score") {
|
||||
const rankings = getRankings ? await getRankings() : new Map<string, number>();
|
||||
return sortComboStepsByScore(steps, rankings);
|
||||
}
|
||||
return sortComboStepsSync(steps, method);
|
||||
}
|
||||
@@ -260,6 +260,12 @@ export const comboRuntimeConfigSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
// Optional client-side sort hint for combo models.
|
||||
// Honored in the dashboard builder; reserved for future server-side use. Inert on execution.
|
||||
modelSort: z
|
||||
.object({ method: z.enum(["manual", "provider", "score", "name"]) })
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.transform((config) => {
|
||||
|
||||
49
tests/_setup/jsdomGlobal.ts
Normal file
49
tests/_setup/jsdomGlobal.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost" });
|
||||
|
||||
for (const key of [
|
||||
"window",
|
||||
"document",
|
||||
"HTMLElement",
|
||||
"Element",
|
||||
"Node",
|
||||
"Event",
|
||||
"CustomEvent",
|
||||
"MouseEvent",
|
||||
"KeyboardEvent",
|
||||
] as const) {
|
||||
try {
|
||||
(globalThis as unknown as Record<string, unknown>)[key] = (
|
||||
dom.window as unknown as Record<string, unknown>
|
||||
)[key];
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: dom.window.navigator,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
} catch {}
|
||||
try {
|
||||
(globalThis as unknown as Record<string, unknown>).getComputedStyle =
|
||||
dom.window.getComputedStyle.bind(dom.window);
|
||||
} catch {}
|
||||
|
||||
if (typeof (globalThis as unknown as { matchMedia?: unknown }).matchMedia !== "function") {
|
||||
(globalThis as unknown as { window: { matchMedia?: unknown } }).window.matchMedia = () =>
|
||||
({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList;
|
||||
(globalThis as unknown as Record<string, unknown>).matchMedia = (
|
||||
globalThis as unknown as { window: { matchMedia: unknown } }
|
||||
).window.matchMedia;
|
||||
}
|
||||
112
tests/unit/combo/comboSort.test.ts
Normal file
112
tests/unit/combo/comboSort.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// tests/unit/combo/comboSort.test.ts
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
PROVIDER_ORDER,
|
||||
sortComboStepsSync,
|
||||
sortComboStepsByScore,
|
||||
reapplyCurrentSort,
|
||||
type ComboStep,
|
||||
} from "@/lib/combos/comboSort";
|
||||
|
||||
const m = (id: string, providerId: string, model: string): ComboStep => ({
|
||||
id,
|
||||
kind: "model",
|
||||
model,
|
||||
providerId,
|
||||
weight: 0,
|
||||
});
|
||||
const ref = (id: string, comboName: string): ComboStep => ({
|
||||
id,
|
||||
kind: "combo-ref",
|
||||
comboName,
|
||||
weight: 0,
|
||||
});
|
||||
|
||||
describe("sortComboStepsSync", () => {
|
||||
it("manual returns the input unchanged", () => {
|
||||
const steps = [m("a", "openai", "gpt"), m("b", "anthropic", "claude")];
|
||||
assert.equal(sortComboStepsSync(steps, "manual"), steps);
|
||||
});
|
||||
|
||||
it("provider groups by PROVIDER_ORDER, stable intra-group, combo-ref at end", () => {
|
||||
const steps = [
|
||||
m("x", "anthropic", "claude-3"),
|
||||
m("y", "openai", "gpt-4"),
|
||||
ref("r", "other-combo"),
|
||||
m("z", "anthropic", "claude-2"),
|
||||
];
|
||||
const out = sortComboStepsSync(steps, "provider");
|
||||
// combo-ref always last (no providerId).
|
||||
assert.equal(out[out.length - 1].id, "r");
|
||||
const nonRef = out.filter((s) => s.id !== "r").map((s) => s.id);
|
||||
// all three model steps are present, and the two anthropic steps stay grouped.
|
||||
assert.deepEqual(nonRef.slice().sort(), ["x", "y", "z"]);
|
||||
const ai = nonRef.indexOf("x");
|
||||
const zi = nonRef.indexOf("z");
|
||||
assert.ok(Math.abs(ai - zi) === 1, "steps of the same provider are adjacent");
|
||||
});
|
||||
|
||||
it("name sorts alphabetically with a stable tiebreak", () => {
|
||||
const steps = [m("a", "openai", "zeta"), m("b", "openai", "alpha"), m("c", "openai", "alpha")];
|
||||
const out = sortComboStepsSync(steps, "name");
|
||||
assert.deepEqual(
|
||||
out.map((s) => s.id),
|
||||
["b", "c", "a"]
|
||||
);
|
||||
});
|
||||
|
||||
it("PROVIDER_ORDER is non-empty and stable", () => {
|
||||
assert.ok(PROVIDER_ORDER.length > 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("score sort", () => {
|
||||
it("sorts scored steps descending, unscored stable at end", async () => {
|
||||
const steps = [
|
||||
m("a", "openai", "gpt"), // score 90
|
||||
m("b", "anthropic", "claude"), // no score
|
||||
m("c", "google", "gemini"), // score 70
|
||||
ref("r", "other"), // no providerId
|
||||
];
|
||||
const rankings = new Map<string, number>([
|
||||
["openai", 90],
|
||||
["google", 70],
|
||||
]);
|
||||
const out = await sortComboStepsByScore(steps, rankings);
|
||||
assert.deepEqual(
|
||||
out.map((s) => s.id),
|
||||
["a", "c", "b", "r"]
|
||||
);
|
||||
});
|
||||
|
||||
it("reapplyCurrentSort applies score async and sync methods", async () => {
|
||||
const steps = [m("a", "openai", "gpt"), m("b", "claude", "claude")];
|
||||
const syncOut = await reapplyCurrentSort(steps, "provider");
|
||||
assert.deepEqual(
|
||||
syncOut.map((s) => s.id),
|
||||
["b", "a"]
|
||||
); // claude (anthropic) before openai
|
||||
const rankOut = await reapplyCurrentSort(steps, "score", async () => new Map([["openai", 5]]));
|
||||
assert.deepEqual(
|
||||
rankOut.map((s) => s.id),
|
||||
["a", "b"]
|
||||
);
|
||||
});
|
||||
|
||||
it("reapplyCurrentSort after an add keeps provider grouping", async () => {
|
||||
const base = [m("a", "anthropic", "claude")];
|
||||
const added = [...base, m("b", "openai", "gpt")];
|
||||
const out = await reapplyCurrentSort(added, "provider");
|
||||
// Expected order derived from PROVIDER_ORDER (robust to provider precedence).
|
||||
const expected = ["a", "b"].sort((x, y) => {
|
||||
const px = x === "a" ? "anthropic" : "openai";
|
||||
const py = y === "a" ? "anthropic" : "openai";
|
||||
return PROVIDER_ORDER.indexOf(px) - PROVIDER_ORDER.indexOf(py);
|
||||
});
|
||||
assert.deepEqual(
|
||||
out.map((s) => s.id),
|
||||
expected
|
||||
);
|
||||
});
|
||||
});
|
||||
18
tests/unit/dashboard/ComboSortSelect.test.tsx
Normal file
18
tests/unit/dashboard/ComboSortSelect.test.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import "../../_setup/jsdomGlobal.ts";
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { ComboSortSelect } from "@/app/(dashboard)/dashboard/combos/ComboSortSelect";
|
||||
|
||||
const t = (_k: string, f: string) => f;
|
||||
|
||||
describe("ComboSortSelect", () => {
|
||||
it("renders four options and emits the chosen method", () => {
|
||||
let chosen = "";
|
||||
render(<ComboSortSelect value="manual" onChange={(mm) => (chosen = mm)} t={t} />);
|
||||
const select = screen.getByRole("combobox");
|
||||
assert.equal((select as HTMLSelectElement).options.length, 4);
|
||||
fireEvent.change(select, { target: { value: "provider" } });
|
||||
assert.equal(chosen, "provider");
|
||||
});
|
||||
});
|
||||
39
tests/unit/shared/comboSchema.test.ts
Normal file
39
tests/unit/shared/comboSchema.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { comboRuntimeConfigSchema, updateComboSchema } from "@/shared/validation/schemas/combo";
|
||||
|
||||
describe("comboRuntimeConfigSchema.modelSort", () => {
|
||||
it("accepts a valid modelSort method", () => {
|
||||
const parsed = comboRuntimeConfigSchema.parse({ modelSort: { method: "provider" } });
|
||||
assert.deepEqual(parsed.modelSort, { method: "provider" });
|
||||
});
|
||||
|
||||
it("rejects an invalid method", () => {
|
||||
assert.throws(() => comboRuntimeConfigSchema.parse({ modelSort: { method: "bogus" } }));
|
||||
});
|
||||
|
||||
it("allows extra keys on the modelSort sub-object (passthrough)", () => {
|
||||
const parsed = comboRuntimeConfigSchema.parse({ modelSort: { method: "name", future: 1 } });
|
||||
assert.equal(parsed.modelSort?.method, "name");
|
||||
});
|
||||
|
||||
it("is optional", () => {
|
||||
const parsed = comboRuntimeConfigSchema.parse({});
|
||||
assert.equal(parsed.modelSort, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelSort persistence", () => {
|
||||
it("round-trips through updateComboSchema (models + config)", () => {
|
||||
const parsed = updateComboSchema.parse({
|
||||
models: [{ id: "m1", kind: "model", model: "openai/gpt", providerId: "openai", weight: 0 }],
|
||||
config: { modelSort: { method: "score" } },
|
||||
});
|
||||
assert.equal(parsed.config?.modelSort?.method, "score");
|
||||
});
|
||||
|
||||
it("passes through comboRuntimeConfigSchema", () => {
|
||||
const parsed = comboRuntimeConfigSchema.parse({ modelSort: { method: "provider" } });
|
||||
assert.equal(parsed.modelSort?.method, "provider");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user