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:
@@ -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();
|
||||
|
||||
354
frontend/src/lib/models/__tests__/TranslationJobModel.test.ts
Normal file
354
frontend/src/lib/models/__tests__/TranslationJobModel.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user