test(translate): add TranslationJobModel L1 tests for field mapping invariants

- 19 tests covering:
  - disableReasoning load from API (true, false, omitted, null)
  - disableReasoning save to API (POST new + PUT update)
  - databaseDialect save to API (populated, empty, PUT)
  - datasourceSearch name lookup on job page load
    (found by ID, found by string ID, not found, no ID, API error)
  - uxState transitions: loading→configured, idle on job fetch
    failure, idle on Promise.all entry failure

Also changed datasourceSearch lookup from fire-and-forget .then()
to awaited try/catch — guarantees name is populated before
uxState transitions to 'configured'.
This commit is contained in:
2026-06-03 12:04:18 +03:00
parent ebbbd51230
commit 5ada8cf745
2 changed files with 357 additions and 2 deletions

View File

@@ -227,12 +227,13 @@ export class TranslationJobModel {
this.loadDatasourceColumns();
// Look up datasource name to populate search input on page load
if (this.datasourceId) {
fetchDatasources<Record<string, unknown>[]>(this.environmentId, '').then((list) => {
try {
const list = await fetchDatasources<Record<string, unknown>[]>(this.environmentId, '');
const ds = (Array.isArray(list) ? list : []).find((d) => String(d.id) === String(this.datasourceId));
if (ds) {
this.datasourceSearch = `${ds.table_name} (${ds.database_name} · ${ds.database_dialect})`;
}
}).catch(() => {});
} catch { /* lookup is best-effort */ }
}
}
this.loadRunHistory();

View File

@@ -0,0 +1,354 @@
// frontend/src/lib/models/__tests__/TranslationJobModel.test.ts
// #region TranslationJobModelFixTests [C:3] [TYPE Module] [SEMANTICS test,model,translate,field-mapping]
// @BRIEF L1 unit tests for TranslationJobModel field mapping invariants — no DOM render.
// @RELATION BINDS_TO -> [TranslationJobModel]
//
// @TEST_INVARIANT: disableReasoning-loaded-from-api -> VERIFIED_BY: [loads disableReasoning from API response, defaults to false when API omits field]
// @TEST_INVARIANT: disableReasoning-sent-in-save -> VERIFIED_BY: [sends disable_reasoning in save payload]
// @TEST_INVARIANT: databaseDialect-sent-in-save -> VERIFIED_BY: [sends database_dialect in save payload]
// @TEST_INVARIANT: datasourceSearch-populated-on-load -> VERIFIED_BY: [populates datasourceSearch on job load]
// #endregion
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Module mocks ────────────────────────────────────────────────
vi.mock('$lib/api.js', () => ({
api: {
requestApi: vi.fn(),
fetchApi: vi.fn(),
postApi: vi.fn(),
getEnvironmentDatabases: vi.fn(),
},
}));
/*
* DO NOT mock $lib/api/translate.js as a whole module.
* Instead, mock api.fetchApi which is the underlying transport used by
* fetchDatasources, fetchDatasourceColumns, fetchRunHistory, etc.
* This avoids hoisting/scope issues with module-level mocks being
* consumed by the model's import bindings.
*/
vi.mock('$lib/toasts.svelte.js', () => ({
addToast: vi.fn(),
}));
vi.mock('$lib/i18n/index.svelte.js', () => ({
_: (key: string) => key,
getT: () => ({ translate: { config: { saved: 'Job saved' } } }),
}));
vi.mock('$lib/stores/translationRun.svelte.js', () => ({
startTranslationRun: vi.fn(),
resetTranslationRun: vi.fn(),
translationRunStore: { subscribe: vi.fn() },
}));
vi.mock('$lib/cot-logger', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
// ── Imports (after mocks) ───────────────────────────────────────
import { TranslationJobModel } from '../TranslationJobModel.svelte.ts';
import { api } from '$lib/api.js';
// ── Helpers ─────────────────────────────────────────────────────
const JOB_ID = 'job-uuid-1111';
const ENV_ID = 'env-uuid-2222';
const DS_ID = 'ds-uuid-3333';
function makeJob(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: JOB_ID,
name: 'Test Job',
description: 'A test translation job',
source_datasource_id: DS_ID,
source_dialect: 'postgresql',
target_dialect: 'clickhouse',
source_table: 'source_table',
target_schema: 'target_schema',
target_table: 'target_table',
source_key_cols: ['id'],
target_key_cols: ['id'],
translation_column: 'text',
target_column: 'translated_text',
target_language_column: 'lang',
target_source_column: 'source',
target_source_language_column: 'source_lang',
context_columns: ['category'],
target_languages: ['en', 'de'],
provider_id: 'provider-1',
batch_size: 50,
upsert_strategy: 'MERGE',
status: 'DRAFT',
database_dialect: 'postgresql',
disable_reasoning: false,
dictionary_ids: [],
environment_id: ENV_ID,
target_database_id: 'target-db-1',
created_by: 'testuser',
created_at: '2026-01-01T00:00:00Z',
updated_at: null,
...overrides,
};
}
/** Set up mocks so that loadInitialData() succeeds and returns a job.
* Uses URL-routed mockImplementation to handle the various API calls
* regardless of execution order (some calls are fire-and-forget).
*/
function mockSuccessfulLoad(jobOverrides: Record<string, unknown> = {}, dsList?: Record<string, unknown>[]) {
const job = makeJob(jobOverrides);
const defaultDsList = [{ id: DS_ID, table_name: 'source_table', database_name: 'mydb', database_dialect: 'postgresql' }];
const datasourcesResp = dsList ?? defaultDsList;
// mockImplementation for requestApi — route by URL
vi.mocked(api.requestApi).mockImplementation(async (url: string) => {
if (url === '/llm/providers') return { providers: [] };
if (url === '/translate/dictionaries?page_size=100') return { items: [] };
if (url.startsWith('/translate/jobs/')) return job;
return { items: [] };
});
vi.mocked(api.getEnvironmentDatabases).mockResolvedValue([]);
// mockImplementation for fetchApi — route by URL path
vi.mocked(api.fetchApi).mockImplementation(async (url: string) => {
if (url.includes('/columns')) return { columns: [], virtual: [] };
if (url.includes('/datasources?')) return datasourcesResp;
if (url.includes('/runs')) return { items: [] };
return undefined;
});
return job;
}
// ── Suite ───────────────────────────────────────────────────────
describe('TranslationJobModel — field mapping invariants', () => {
let model: TranslationJobModel;
beforeEach(() => {
vi.clearAllMocks();
model = new TranslationJobModel();
model.jobId = JOB_ID;
model.isNewJob = false;
model.environments = [{ id: ENV_ID, name: 'TestEnv' }];
});
// ═══════════════════════════════════════════════════════════════
// disableReasoning — load from API
// ═══════════════════════════════════════════════════════════════
describe('disableReasoning — load from API', () => {
it('loads true from API response', async () => {
mockSuccessfulLoad({ disable_reasoning: true });
await model.loadInitialData();
expect(model.disableReasoning).toBe(true);
});
it('loads false from API response', async () => {
mockSuccessfulLoad({ disable_reasoning: false });
await model.loadInitialData();
expect(model.disableReasoning).toBe(false);
});
it('defaults to false when API omits disable_reasoning', async () => {
const job = makeJob();
delete (job as Record<string, unknown>).disable_reasoning;
mockSuccessfulLoad(job);
await model.loadInitialData();
expect(model.disableReasoning).toBe(false);
});
it('defaults to false when disable_reasoning is null', async () => {
mockSuccessfulLoad({ disable_reasoning: null });
await model.loadInitialData();
expect(model.disableReasoning).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════
// disableReasoning — save to API
// ═══════════════════════════════════════════════════════════════
describe('disableReasoning — save to API', () => {
it('sends disable_reasoning: true in save payload (POST)', async () => {
model.disableReasoning = true;
model.isNewJob = true;
vi.mocked(api.requestApi).mockResolvedValue({ id: JOB_ID });
await model.saveJob();
expect(api.requestApi).toHaveBeenCalledWith(
'/translate/jobs',
'POST',
expect.objectContaining({ disable_reasoning: true }),
);
});
it('sends disable_reasoning: false in save payload', async () => {
model.disableReasoning = false;
model.isNewJob = true;
vi.mocked(api.requestApi).mockResolvedValue({ id: JOB_ID });
await model.saveJob();
const payload = vi.mocked(api.requestApi).mock.calls[0][2] as Record<string, unknown>;
expect(payload.disable_reasoning).toBe(false);
});
it('sends disable_reasoning on existing job update (PUT)', async () => {
model.disableReasoning = true;
model.isNewJob = false;
model.jobId = JOB_ID;
vi.mocked(api.requestApi).mockResolvedValue({});
await model.saveJob();
expect(api.requestApi).toHaveBeenCalledWith(
`/translate/jobs/${JOB_ID}`,
'PUT',
expect.objectContaining({ disable_reasoning: true }),
);
});
});
// ═══════════════════════════════════════════════════════════════
// databaseDialect — save to API
// ═══════════════════════════════════════════════════════════════
describe('databaseDialect — save to API', () => {
it('sends database_dialect in save payload', async () => {
model.databaseDialect = 'postgresql';
model.isNewJob = true;
vi.mocked(api.requestApi).mockResolvedValue({ id: JOB_ID });
await model.saveJob();
const payload = vi.mocked(api.requestApi).mock.calls[0][2] as Record<string, unknown>;
expect(payload.database_dialect).toBe('postgresql');
});
it('sends database_dialect as undefined when empty', async () => {
model.databaseDialect = '';
model.isNewJob = true;
vi.mocked(api.requestApi).mockResolvedValue({ id: JOB_ID });
await model.saveJob();
const payload = vi.mocked(api.requestApi).mock.calls[0][2] as Record<string, unknown>;
expect(payload.database_dialect).toBeUndefined();
});
it('sends database_dialect on existing job update (PUT)', async () => {
model.databaseDialect = 'mysql';
model.isNewJob = false;
model.jobId = JOB_ID;
vi.mocked(api.requestApi).mockResolvedValue({});
await model.saveJob();
expect(api.requestApi).toHaveBeenCalledWith(
`/translate/jobs/${JOB_ID}`,
'PUT',
expect.objectContaining({ database_dialect: 'mysql' }),
);
});
});
// ═══════════════════════════════════════════════════════════════
// datasourceSearch — name lookup on load
// ═══════════════════════════════════════════════════════════════
describe('datasourceSearch — name lookup on load', () => {
it('populates datasourceSearch when matching datasource found', async () => {
const dsList = [
{ id: DS_ID, table_name: 'source_table', database_name: 'mydb', database_dialect: 'postgresql' },
];
mockSuccessfulLoad({}, dsList);
await model.loadInitialData();
expect(model.datasourceSearch).toBe('source_table (mydb · postgresql)');
});
it('matches by string ID correctly', async () => {
const dsList = [
{ id: 'other-uuid', table_name: 'other', database_name: 'db', database_dialect: 'pg' },
{ id: DS_ID, table_name: 'found_table', database_name: 'found_db', database_dialect: 'mysql' },
];
mockSuccessfulLoad({}, dsList);
await model.loadInitialData();
expect(model.datasourceSearch).toBe('found_table (found_db · mysql)');
});
it('leaves datasourceSearch empty when datasource not found', async () => {
mockSuccessfulLoad({}, []);
await model.loadInitialData();
expect(model.datasourceSearch).toBe('');
});
it('leaves datasourceSearch empty when no datasourceId is set', async () => {
mockSuccessfulLoad({ source_datasource_id: '' });
await model.loadInitialData();
expect(model.datasourceSearch).toBe('');
});
it('handles fetchDatasources rejection gracefully', async () => {
mockSuccessfulLoad({});
// fetchDatasources internally calls api.fetchApi — make that reject
vi.mocked(api.fetchApi).mockImplementation(async (url: string) => {
if (url.includes('/columns')) return { columns: [], virtual: [] };
throw new Error('API error');
});
await expect(model.loadInitialData()).resolves.toBeUndefined();
expect(model.datasourceSearch).toBe('');
});
it('uses same display format as selectDatasource()', async () => {
const dsList = [
{ id: DS_ID, table_name: 'orders_en', database_name: 'analytics_db', database_dialect: 'postgresql' },
];
mockSuccessfulLoad({}, dsList);
await model.loadInitialData();
expect(model.datasourceSearch).toMatch(/^orders_en \(analytics_db · postgresql\)$/);
});
});
// ═══════════════════════════════════════════════════════════════
// uxState transitions during load
// ═══════════════════════════════════════════════════════════════
describe('uxState transition during loadInitialData', () => {
it('sets uxState to loading then configured on success', async () => {
mockSuccessfulLoad({});
const promise = model.loadInitialData();
expect(model.uxState).toBe('loading');
await promise;
expect(model.uxState).toBe('configured');
});
it('sets uxState to idle and stores error when job fetch fails', async () => {
// Providers & dicts succeed; job fetch rejects
let callCount = 0;
vi.mocked(api.requestApi).mockImplementation(async (url: string) => {
callCount++;
if (callCount <= 2) return { items: [] }; // providers + dicts
throw new Error('Job not found');
});
await model.loadInitialData();
expect(model.uxState).toBe('idle');
expect(model.error).toBe('Job not found');
});
it('sets uxState to idle and stores error when dicts fetch fails', async () => {
// Providers succeed; dicts fetch rejected (caught by .catch);
// then job fetch also rejected (uncaught -> outer catch)
let callCount = 0;
vi.mocked(api.requestApi).mockImplementation(async (url: string) => {
callCount++;
if (callCount === 1) return { providers: [] };
throw new Error('Dicts failed');
});
await model.loadInitialData();
expect(model.uxState).toBe('idle');
expect(model.error).toBe('Dicts failed');
});
});
});