Files
ss-tools/frontend/src/lib/models/__tests__/TranslateHistoryModel.test.ts
busya 3b13c67c0d feat(translate): language detection, async HTTP LLM, history model, agent improvements
- Add async HTTP-based LLM transport (_llm_async_http.py)
- Add orthogonal LLM call tests
- Improve language detection (_lang_detect.py) and batch insert
- Update translate schemas, service utils, preview constants/prompts
- Add TranslateHistoryModel with pagination and filtering
- Update agent confirmation, persistence, langgraph setup, run, tools
- Improve LLM health checking in shared module
- Update translate runs API, history route
2026-07-08 19:35:49 +03:00

406 lines
15 KiB
TypeScript

// #region TranslateHistoryModelTests [C:3] [TYPE Module]
// @BRIEF L1 unit tests for TranslateHistoryModel — no DOM render.
// @RELATION BINDS_TO -> [TranslateHistoryModel]
// @TEST_INVARIANT: filter-resets-pagination -> VERIFIED_BY: [test_applyFilters_resets_page]
// @TEST_INVARIANT: detail-panel-gate -> VERIFIED_BY: [test_openDetail_closeDetail]
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("$lib/toasts.svelte.js", () => ({ addToast: vi.fn() }));
vi.mock("$lib/i18n/index.svelte.js", () => ({ getT: () => ({}), _: vi.fn((k) => k) }));
vi.mock("$lib/api/translate.js", () => ({
fetchAllRuns: vi.fn().mockResolvedValue([]),
fetchRunDetail: vi.fn(),
fetchAllMetrics: vi.fn(),
fetchJobs: vi.fn(),
downloadSkippedCsv: vi.fn(),
downloadFailedCsv: vi.fn(),
cancelRun: vi.fn(),
retryFailedBatches: vi.fn(),
}));
import { TranslateHistoryModel } from "../TranslateHistoryModel.svelte.ts";
import { addToast } from "$lib/toasts.svelte.js";
import {
fetchAllRuns, fetchRunDetail, fetchAllMetrics, fetchJobs,
downloadSkippedCsv, downloadFailedCsv, cancelRun, retryFailedBatches,
} from "$lib/api/translate.js";
// #region TranslateHistoryModel.InvariantTests [C:2] [TYPE Function]
describe("TranslateHistoryModel — L1 invariants", () => {
let model: TranslateHistoryModel;
beforeEach(() => { vi.clearAllMocks(); model = new TranslateHistoryModel(); });
// #region TranslateHistoryModel.InitialState [C:2] [TYPE Test]
it("starts in idle state", () => {
expect(model.uxState).toBe("idle");
expect(model.runs).toEqual([]);
});
// #endregion
// #region TranslateHistoryModel.ApplyFilters [C:2] [TYPE Test]
it("applyFilters resets currentPage to 1", () => {
model.currentPage = 3;
model.applyFilters();
expect(model.currentPage).toBe(1);
});
// #endregion
// #region TranslateHistoryModel.DetailPanel [C:2] [TYPE Test]
it("openDetail sets selectedRun and loads detail", async () => {
vi.mocked(fetchRunDetail).mockResolvedValue({ id: "r1", metrics: {} });
const run = { id: "r1", status: "completed" };
await model.openDetail(run);
expect(model.selectedRun).toEqual(run);
expect(model.uxState).toBe("detail_open");
});
it("openDetail handles API error", async () => {
vi.mocked(fetchRunDetail).mockRejectedValue(new Error("Detail failed"));
await model.openDetail({ id: "r1" });
expect(addToast).toHaveBeenCalled();
});
it("closeDetail clears selectedRun and detail", () => {
model.selectedRun = { id: "r1" } as any;
model.selectedRunDetail = { metrics: {} } as any;
model.closeDetail();
expect(model.selectedRun).toBeNull();
expect(model.selectedRunDetail).toBeNull();
expect(model.uxState).toBe("empty");
});
it("closeDetail restores populated uxState when runs exist", () => {
model.selectedRun = { id: "r1" } as any;
model.selectedRunDetail = { metrics: {} } as any;
model.runs = [{ id: "r1" }] as any;
model.closeDetail();
expect(model.uxState).toBe("populated");
});
// #endregion
});
// #endregion
// #region TranslateHistoryModel.DataLoading [C:2] [TYPE Function]
describe("TranslateHistoryModel — Data Loading", () => {
let model: TranslateHistoryModel;
beforeEach(() => { vi.clearAllMocks(); model = new TranslateHistoryModel(); });
// #region TranslateHistoryModel.LoadRuns [C:2] [TYPE Test]
it("loadRuns sets loading state and populates runs", async () => {
vi.mocked(fetchAllRuns).mockResolvedValue({ items: [{ id: "r1" }, { id: "r2" }], total: 2 });
model.filterDateFrom = "2026-07-01";
model.filterDateTo = "2026-07-08";
const promise = model.loadRuns();
expect(model.isLoading).toBe(true);
expect(model.uxState).toBe("loading");
await promise;
expect(fetchAllRuns).toHaveBeenCalledWith(expect.objectContaining({
date_from: "2026-07-01",
date_to: "2026-07-08",
}));
expect(model.runs).toHaveLength(2);
expect(model.total).toBe(2);
expect(model.uxState).toBe("populated");
expect(model.isLoading).toBe(false);
});
it("loadRuns sets empty state when no results", async () => {
vi.mocked(fetchAllRuns).mockResolvedValue({ items: [], total: 0 });
await model.loadRuns();
expect(model.uxState).toBe("empty");
});
it("loadRuns handles API error", async () => {
vi.mocked(fetchAllRuns).mockRejectedValue(new Error("Load failed"));
await model.loadRuns();
expect(model.uxState).toBe("empty");
expect(addToast).toHaveBeenCalled();
expect(model.isLoading).toBe(false);
});
// #endregion
// #region TranslateHistoryModel.LoadMetrics [C:2] [TYPE Test]
it("loadMetrics populates metrics array", async () => {
vi.mocked(fetchAllMetrics).mockResolvedValue([{ total_runs: 10, successful_runs: 8, failed_runs: 2, total_records: 100 }]);
await model.loadMetrics();
expect(model.metrics).toHaveLength(1);
});
it("loadMetrics silently ignores error", async () => {
vi.mocked(fetchAllMetrics).mockRejectedValue(new Error("Metrics down"));
await model.loadMetrics();
expect(model.metrics).toEqual([]);
});
// #endregion
// #region TranslateHistoryModel.LoadJobs [C:2] [TYPE Test]
it("loadJobs accepts array result", async () => {
vi.mocked(fetchJobs).mockResolvedValue([{ id: "j1", name: "Job 1" }]);
await model.loadJobs();
expect(model.jobs).toHaveLength(1);
});
it("loadJobs accepts items format result", async () => {
vi.mocked(fetchJobs).mockResolvedValue({ items: [{ id: "j1", name: "Job 1" }] });
await model.loadJobs();
expect(model.jobs).toHaveLength(1);
});
it("loadJobs fetches all pages when total exceeds first page", async () => {
vi.mocked(fetchJobs)
.mockResolvedValueOnce({ items: Array.from({ length: 100 }, (_, i) => ({ id: `j${i}` })), total: 101 })
.mockResolvedValueOnce({ items: [{ id: "j100", name: "Job 100" }], total: 101 });
await model.loadJobs();
expect(fetchJobs).toHaveBeenCalledWith({ page: 1, page_size: 100 });
expect(fetchJobs).toHaveBeenCalledWith({ page: 2, page_size: 100 });
expect(model.jobs).toHaveLength(101);
});
it("loadJobs accepts results format result", async () => {
vi.mocked(fetchJobs).mockResolvedValue({ results: [{ id: "j1", name: "Job 1" }] });
await model.loadJobs();
expect(model.jobs).toHaveLength(1);
});
it("loadJobs silently ignores error", async () => {
vi.mocked(fetchJobs).mockRejectedValue(new Error("Jobs down"));
await model.loadJobs();
expect(model.jobs).toEqual([]);
});
it("loadJobs handles response without items or results field", async () => {
vi.mocked(fetchJobs).mockResolvedValue({ other_field: "no items" });
await model.loadJobs();
expect(model.jobs).toEqual([]);
});
// #endregion
// #region TranslateHistoryModel.NonErrorExceptions [C:2] [TYPE Test]
it("loadRuns handles non-Error rejections", async () => {
vi.mocked(fetchAllRuns).mockRejectedValue("string error");
await model.loadRuns();
expect(model.uxState).toBe("empty");
expect(model.isLoading).toBe(false);
});
it("loadMetrics handles non-Error rejections", async () => {
vi.mocked(fetchAllMetrics).mockRejectedValue("metrics error");
await model.loadMetrics();
expect(model.metrics).toEqual([]);
});
it("loadJobs handles non-Error rejections", async () => {
vi.mocked(fetchJobs).mockRejectedValue("jobs error");
await model.loadJobs();
expect(model.jobs).toEqual([]);
});
// #endregion
});
// #endregion
// #region TranslateHistoryModel.RunActions [C:2] [TYPE Function]
describe("TranslateHistoryModel — Run Actions", () => {
let model: TranslateHistoryModel;
beforeEach(() => { vi.clearAllMocks(); model = new TranslateHistoryModel(); });
// #region TranslateHistoryModel.CancelRun [C:2] [TYPE Test]
it("handleCancelRun calls cancel API and refreshes runs", async () => {
vi.mocked(cancelRun).mockResolvedValue({});
vi.mocked(fetchAllRuns).mockResolvedValue({ items: [], total: 0 });
await model.handleCancelRun("r1");
expect(cancelRun).toHaveBeenCalledWith("r1");
expect(model.cancellingRunId).toBeNull();
});
it("handleCancelRun handles error", async () => {
vi.mocked(cancelRun).mockRejectedValue(new Error("Cancel failed"));
await model.handleCancelRun("r1");
expect(addToast).toHaveBeenCalled();
expect(model.cancellingRunId).toBeNull();
});
it("handleCancelRun refreshes selectedRunDetail if matches", async () => {
vi.mocked(cancelRun).mockResolvedValue({});
vi.mocked(fetchRunDetail).mockResolvedValue({ id: "r1", status: "cancelled" });
vi.mocked(fetchAllRuns).mockResolvedValue({ items: [], total: 0 });
model.selectedRunDetail = { id: "r1" } as any;
await model.handleCancelRun("r1");
expect(fetchRunDetail).toHaveBeenCalled();
});
// #endregion
// #region TranslateHistoryModel.RetryRun [C:2] [TYPE Test]
it("handleRetryRun calls retry API and refreshes runs", async () => {
vi.mocked(retryFailedBatches).mockResolvedValue({});
vi.mocked(fetchAllRuns).mockResolvedValue({ items: [], total: 0 });
await model.handleRetryRun("r1");
expect(retryFailedBatches).toHaveBeenCalledWith("r1");
expect(model.retryingRunId).toBeNull();
});
it("handleRetryRun handles error", async () => {
vi.mocked(retryFailedBatches).mockRejectedValue(new Error("Retry failed"));
await model.handleRetryRun("r1");
expect(addToast).toHaveBeenCalled();
expect(model.retryingRunId).toBeNull();
});
// #endregion
// #region TranslateHistoryModel.Downloads [C:2] [TYPE Test]
it("handleDownloadSkipped calls API", async () => {
vi.mocked(downloadSkippedCsv).mockResolvedValue(undefined);
await model.handleDownloadSkipped("r1");
expect(downloadSkippedCsv).toHaveBeenCalledWith("r1");
});
it("handleDownloadSkipped handles error", async () => {
vi.mocked(downloadSkippedCsv).mockRejectedValue(new Error("Download failed"));
await model.handleDownloadSkipped("r1");
expect(addToast).toHaveBeenCalled();
});
it("handleDownloadSkipped handles non-Error rejection", async () => {
vi.mocked(downloadSkippedCsv).mockRejectedValue("string error");
await model.handleDownloadSkipped("r1");
expect(addToast).toHaveBeenCalled();
});
it("handleDownloadFailed calls API", async () => {
vi.mocked(downloadFailedCsv).mockResolvedValue(undefined);
await model.handleDownloadFailed("r1");
expect(downloadFailedCsv).toHaveBeenCalledWith("r1");
});
it("handleDownloadFailed handles error", async () => {
vi.mocked(downloadFailedCsv).mockRejectedValue(new Error("Download failed"));
await model.handleDownloadFailed("r1");
expect(addToast).toHaveBeenCalled();
});
it("handleDownloadFailed handles non-Error rejection", async () => {
vi.mocked(downloadFailedCsv).mockRejectedValue("string error");
await model.handleDownloadFailed("r1");
expect(addToast).toHaveBeenCalled();
});
it("handleCancelRun handles non-Error rejection", async () => {
vi.mocked(cancelRun).mockRejectedValue("cancel string error");
await model.handleCancelRun("r1");
expect(addToast).toHaveBeenCalled();
expect(model.cancellingRunId).toBeNull();
});
it("handleRetryRun handles non-Error rejection", async () => {
vi.mocked(retryFailedBatches).mockRejectedValue("retry string error");
await model.handleRetryRun("r1");
expect(addToast).toHaveBeenCalled();
expect(model.retryingRunId).toBeNull();
});
it("openDetail handles non-Error rejection", async () => {
vi.mocked(fetchRunDetail).mockRejectedValue("string error");
await model.openDetail({ id: "r1" });
expect(addToast).toHaveBeenCalled();
});
// #endregion
});
// #endregion
// #region TranslateHistoryModel.Utilities [C:2] [TYPE Function]
describe("TranslateHistoryModel — Utilities", () => {
let model: TranslateHistoryModel;
beforeEach(() => { vi.clearAllMocks(); model = new TranslateHistoryModel(); });
// #region TranslateHistoryModel.GetStatusClass [C:2] [TYPE Test]
it("getStatusClass returns correct classes for PENDING", () => {
expect(model.getStatusClass("PENDING")).toContain("warning");
});
it("getStatusClass returns correct classes for COMPLETED", () => {
expect(model.getStatusClass("COMPLETED")).toContain("success");
});
it("getStatusClass returns correct classes for FAILED", () => {
expect(model.getStatusClass("FAILED")).toContain("destructive");
});
it("getStatusClass returns muted for unknown status", () => {
expect(model.getStatusClass("UNKNOWN")).toContain("muted");
});
// #endregion
// #region TranslateHistoryModel.GetJobName [C:2] [TYPE Test]
it("getJobName returns job name when found", () => {
model.jobs = [{ id: "j1", name: "My Job" }] as any;
expect(model.getJobName("j1")).toBe("My Job");
});
it("getJobName returns truncated id when not found", () => {
model.jobs = [];
expect(model.getJobName("abcdef123456")).toBe("abcdef12...");
});
it("getJobName returns default for undefined", () => {
const name = model.getJobName(undefined);
expect(name).toContain("...");
});
// #endregion
// #region TranslateHistoryModel.TotalMetric [C:2] [TYPE Test]
it("totalMetric returns null when no metrics", () => {
expect(model.totalMetric).toBeNull();
});
it("totalMetric aggregates metrics", () => {
model.metrics = [
{ total_runs: 10, successful_runs: 8, failed_runs: 2, total_records: 100 },
{ total_runs: 5, successful_runs: 4, failed_runs: 1, total_records: 50 },
] as any;
expect(model.totalMetric.total_runs).toBe(15);
expect(model.totalMetric.successful_runs).toBe(12);
expect(model.totalMetric.failed_runs).toBe(3);
expect(model.totalMetric.total_records).toBe(150);
});
// #endregion
// #region TranslateHistoryModel.StatusClassEdge [C:2] [TYPE Test]
it("getStatusClass returns default for undefined status", () => {
expect(model.getStatusClass(undefined)).toContain("muted");
});
it("getStatusClass returns default for empty string status", () => {
expect(model.getStatusClass("")).toContain("muted");
});
// #endregion
// #region TranslateHistoryModel.JobNameEdge [C:2] [TYPE Test]
it("getJobName returns String(undefined) when job has no name", () => {
model.jobs = [{ id: "j1" }] as any;
// job is found but name is undefined => String(undefined) => "undefined"
expect(model.getJobName("j1")).toBe("undefined");
});
it("getJobName returns empty for undefined job id", () => {
expect(model.getJobName(undefined)).toBe("...");
});
// #endregion
// #region TranslateHistoryModel.RunDetailNonMatch [C:2] [TYPE Test]
it("handleCancelRun does not refresh detail when detail id differs", async () => {
vi.mocked(cancelRun).mockResolvedValue({});
vi.mocked(fetchAllRuns).mockResolvedValue({ items: [], total: 0 });
model.selectedRunDetail = { id: "other-run" } as any;
await model.handleCancelRun("r1");
// fetchRunDetail should NOT be called because selectedRunDetail.id !== runId
expect(fetchRunDetail).not.toHaveBeenCalled();
});
it("handleRetryRun does not refresh detail when detail id differs", async () => {
vi.mocked(retryFailedBatches).mockResolvedValue({});
vi.mocked(fetchAllRuns).mockResolvedValue({ items: [], total: 0 });
model.selectedRunDetail = { id: "other-run" } as any;
await model.handleRetryRun("r1");
expect(fetchRunDetail).not.toHaveBeenCalled();
});
// #endregion
});
// #endregion