Changeset 0.9.0 (#50)
This commit is contained in:
317
src/js/__tests__/model-processing.test.js
Normal file
317
src/js/__tests__/model-processing.test.js
Normal file
@@ -0,0 +1,317 @@
|
||||
// ==========================================
|
||||
// Model Processing Tests
|
||||
// ==========================================
|
||||
// Tests the data transforms in fetchModels()
|
||||
// that convert backend responses into the
|
||||
// App.models array used throughout the UI.
|
||||
//
|
||||
// Run: node --test src/js/__tests__/model-processing.test.js
|
||||
// ==========================================
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { processModelsResponse } = require('./helpers');
|
||||
|
||||
// ── Basic model transform ────────────────────
|
||||
|
||||
describe('processModelsResponse — catalog models', () => {
|
||||
// Mock uses BOTH canonical (provider_config_id) and alias (config_id)
|
||||
// to match the actual UserModel Go struct serialization.
|
||||
const apiResponse = {
|
||||
models: [
|
||||
{
|
||||
id: 'entry-uuid',
|
||||
model_id: 'gpt-4o',
|
||||
display_name: 'GPT-4o',
|
||||
provider_config_id: 'cfg-uuid',
|
||||
config_id: 'cfg-uuid',
|
||||
provider_name: 'OpenAI Production',
|
||||
provider_type: 'openai',
|
||||
source: 'catalog',
|
||||
scope: 'global',
|
||||
capabilities: { streaming: true, vision: true, tool_calling: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('produces composite ID for catalog models', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].id, 'cfg-uuid:gpt-4o',
|
||||
'catalog model ID must be config_id:model_id to avoid collisions');
|
||||
});
|
||||
|
||||
it('produces composite ID with ONLY provider_config_id (no config_id alias)', () => {
|
||||
// This is the critical regression test: if the config_id alias
|
||||
// is removed from the Go struct, the frontend must still work.
|
||||
const resp = {
|
||||
models: [{
|
||||
model_id: 'gpt-4o',
|
||||
provider_config_id: 'cfg-uuid',
|
||||
// NO config_id — simulates broken alias
|
||||
provider_name: 'OpenAI',
|
||||
source: 'catalog',
|
||||
}],
|
||||
};
|
||||
const models = processModelsResponse(resp);
|
||||
assert.equal(models[0].id, 'cfg-uuid:gpt-4o',
|
||||
'must fall back to provider_config_id when config_id is missing');
|
||||
});
|
||||
|
||||
it('preserves baseModelId', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].baseModelId, 'gpt-4o');
|
||||
});
|
||||
|
||||
it('uses display_name for name', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].name, 'GPT-4o');
|
||||
});
|
||||
|
||||
it('falls back to model_id when display_name empty', () => {
|
||||
const resp = {
|
||||
models: [{ model_id: 'claude-3-opus', provider_config_id: 'c1', provider_type: 'anthropic' }],
|
||||
};
|
||||
const models = processModelsResponse(resp);
|
||||
assert.equal(models[0].name, 'claude-3-opus');
|
||||
});
|
||||
|
||||
it('marks catalog models as NOT presets', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].isPreset, false);
|
||||
});
|
||||
|
||||
it('preserves configId from config_id || provider_config_id', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].configId, 'cfg-uuid');
|
||||
});
|
||||
|
||||
it('uses provider_name for display', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].provider, 'OpenAI Production');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Preset transform ─────────────────────────
|
||||
|
||||
describe('processModelsResponse — presets', () => {
|
||||
const apiResponse = {
|
||||
models: [
|
||||
{
|
||||
id: 'preset-uuid',
|
||||
model_id: 'gpt-4o',
|
||||
display_name: 'Code Helper',
|
||||
// Backend canonical fields
|
||||
provider_config_id: 'cfg-uuid',
|
||||
persona_id: 'preset-uuid',
|
||||
scope: 'global',
|
||||
avatar: '/avatars/code.png',
|
||||
source: 'persona',
|
||||
// Frontend alias fields
|
||||
is_preset: true,
|
||||
preset_id: 'preset-uuid',
|
||||
preset_scope: 'global',
|
||||
preset_avatar: '/avatars/code.png',
|
||||
preset_team_name: null,
|
||||
config_id: 'cfg-uuid',
|
||||
provider_name: 'OpenAI',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('uses preset_id as ID (not composite)', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].id, 'preset-uuid',
|
||||
'preset ID must use preset_id, not config_id:model_id');
|
||||
});
|
||||
|
||||
it('falls back to persona_id when preset_id is missing', () => {
|
||||
const resp = {
|
||||
models: [{
|
||||
id: 'p-uuid', model_id: 'gpt-4o',
|
||||
is_preset: true, persona_id: 'p-uuid',
|
||||
// NO preset_id — simulates broken alias
|
||||
source: 'persona',
|
||||
}],
|
||||
};
|
||||
const models = processModelsResponse(resp);
|
||||
assert.equal(models[0].id, 'p-uuid',
|
||||
'must fall back to persona_id when preset_id is missing');
|
||||
});
|
||||
|
||||
it('marks as preset', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].isPreset, true);
|
||||
});
|
||||
|
||||
it('preserves preset metadata', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].presetScope, 'global');
|
||||
assert.equal(models[0].presetAvatar, '/avatars/code.png');
|
||||
assert.equal(models[0].presetId, 'preset-uuid');
|
||||
});
|
||||
|
||||
it('baseModelId is the underlying model', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].baseModelId, 'gpt-4o');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Same model from multiple providers ───────
|
||||
|
||||
describe('processModelsResponse — multi-provider dedup', () => {
|
||||
const apiResponse = {
|
||||
models: [
|
||||
{
|
||||
model_id: 'gpt-4o', display_name: 'GPT-4o',
|
||||
provider_config_id: 'global-cfg', config_id: 'global-cfg',
|
||||
provider_name: 'Global OpenAI',
|
||||
source: 'catalog', scope: 'global',
|
||||
},
|
||||
{
|
||||
model_id: 'gpt-4o', display_name: 'GPT-4o (BYOK)',
|
||||
provider_config_id: 'personal-cfg', config_id: 'personal-cfg',
|
||||
provider_name: 'My Key',
|
||||
source: 'catalog', scope: 'personal',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('same model_id with different config_ids produces unique IDs', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models.length, 2);
|
||||
assert.notEqual(models[0].id, models[1].id,
|
||||
'same model from different providers MUST have unique IDs');
|
||||
assert.equal(models[0].id, 'global-cfg:gpt-4o');
|
||||
assert.equal(models[1].id, 'personal-cfg:gpt-4o');
|
||||
});
|
||||
|
||||
it('works with only provider_config_id (no config_id alias)', () => {
|
||||
const resp = {
|
||||
models: [
|
||||
{ model_id: 'gpt-4o', provider_config_id: 'cfg-a', source: 'catalog' },
|
||||
{ model_id: 'gpt-4o', provider_config_id: 'cfg-b', source: 'catalog' },
|
||||
],
|
||||
};
|
||||
const models = processModelsResponse(resp);
|
||||
assert.equal(models[0].id, 'cfg-a:gpt-4o');
|
||||
assert.equal(models[1].id, 'cfg-b:gpt-4o');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Hidden models ────────────────────────────
|
||||
|
||||
describe('processModelsResponse — hidden models', () => {
|
||||
const apiResponse = {
|
||||
models: [
|
||||
{ model_id: 'gpt-4o', provider_config_id: 'c1', config_id: 'c1', display_name: 'GPT-4o' },
|
||||
{ model_id: 'claude-3', provider_config_id: 'c2', config_id: 'c2', display_name: 'Claude 3' },
|
||||
],
|
||||
};
|
||||
|
||||
it('marks models as hidden from user prefs', () => {
|
||||
const hidden = new Set(['gpt-4o']);
|
||||
const models = processModelsResponse(apiResponse, hidden);
|
||||
assert.equal(models[0].hidden, true, 'gpt-4o should be hidden');
|
||||
assert.equal(models[1].hidden, false, 'claude-3 should NOT be hidden');
|
||||
});
|
||||
|
||||
it('presets are never hidden via model ID', () => {
|
||||
const resp = {
|
||||
models: [
|
||||
{ model_id: 'gpt-4o', is_preset: true, preset_id: 'p1', display_name: 'Preset' },
|
||||
],
|
||||
};
|
||||
const hidden = new Set(['gpt-4o']);
|
||||
const models = processModelsResponse(resp, hidden);
|
||||
assert.equal(models[0].hidden, false,
|
||||
'presets must NOT be hidden by base model hidden pref');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Empty / edge cases ───────────────────────
|
||||
|
||||
describe('processModelsResponse — edge cases', () => {
|
||||
it('handles empty models array', () => {
|
||||
const models = processModelsResponse({ models: [] });
|
||||
assert.equal(models.length, 0);
|
||||
});
|
||||
|
||||
it('handles missing models key', () => {
|
||||
const models = processModelsResponse({});
|
||||
assert.equal(models.length, 0);
|
||||
});
|
||||
|
||||
it('handles null response gracefully', () => {
|
||||
const models = processModelsResponse({ models: null });
|
||||
assert.equal(models.length, 0);
|
||||
});
|
||||
|
||||
it('model without config_id uses bare model_id as ID', () => {
|
||||
const resp = {
|
||||
models: [{ model_id: 'test-model', display_name: 'Test' }],
|
||||
};
|
||||
const models = processModelsResponse(resp);
|
||||
assert.equal(models[0].id, 'test-model');
|
||||
});
|
||||
|
||||
it('model without display_name or model_id uses id', () => {
|
||||
const resp = {
|
||||
models: [{ id: 'fallback-id' }],
|
||||
};
|
||||
const models = processModelsResponse(resp);
|
||||
assert.equal(models[0].id, 'fallback-id');
|
||||
assert.equal(models[0].name, 'fallback-id');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Model sorting ────────────────────────────
|
||||
|
||||
describe('Model sorting', () => {
|
||||
function sortModels(models) {
|
||||
const scopeOrder = { global: 0, team: 1, personal: 2 };
|
||||
return [...models].sort((a, b) => {
|
||||
if (a.isPreset && !b.isPreset) return -1;
|
||||
if (!a.isPreset && b.isPreset) return 1;
|
||||
if (a.isPreset && b.isPreset) {
|
||||
const sa = scopeOrder[a.presetScope] ?? 9;
|
||||
const sb = scopeOrder[b.presetScope] ?? 9;
|
||||
if (sa !== sb) return sa - sb;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
it('presets sort before regular models', () => {
|
||||
const models = [
|
||||
{ name: 'GPT-4o', isPreset: false },
|
||||
{ name: 'Code Helper', isPreset: true, presetScope: 'global' },
|
||||
];
|
||||
const sorted = sortModels(models);
|
||||
assert.equal(sorted[0].name, 'Code Helper');
|
||||
assert.equal(sorted[1].name, 'GPT-4o');
|
||||
});
|
||||
|
||||
it('global presets sort before team presets', () => {
|
||||
const models = [
|
||||
{ name: 'Team Bot', isPreset: true, presetScope: 'team' },
|
||||
{ name: 'Global Bot', isPreset: true, presetScope: 'global' },
|
||||
{ name: 'My Bot', isPreset: true, presetScope: 'personal' },
|
||||
];
|
||||
const sorted = sortModels(models);
|
||||
assert.equal(sorted[0].presetScope, 'global');
|
||||
assert.equal(sorted[1].presetScope, 'team');
|
||||
assert.equal(sorted[2].presetScope, 'personal');
|
||||
});
|
||||
|
||||
it('regular models sort alphabetically', () => {
|
||||
const models = [
|
||||
{ name: 'Zephyr', isPreset: false },
|
||||
{ name: 'Claude', isPreset: false },
|
||||
{ name: 'GPT-4o', isPreset: false },
|
||||
];
|
||||
const sorted = sortModels(models);
|
||||
assert.equal(sorted[0].name, 'Claude');
|
||||
assert.equal(sorted[1].name, 'GPT-4o');
|
||||
assert.equal(sorted[2].name, 'Zephyr');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user