320 lines
12 KiB
JavaScript
320 lines
12 KiB
JavaScript
// ==========================================
|
|
// 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 personas', () => {
|
|
const models = processModelsResponse(apiResponse);
|
|
assert.equal(models[0].isPersona, 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 — personas', () => {
|
|
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_persona: true,
|
|
persona_id: 'preset-uuid',
|
|
persona_scope: 'global',
|
|
persona_avatar: '/avatars/code.png',
|
|
persona_handle: 'code-helper',
|
|
persona_team_name: null,
|
|
config_id: 'cfg-uuid',
|
|
provider_name: 'OpenAI',
|
|
},
|
|
],
|
|
};
|
|
|
|
it('uses persona_id as ID (not composite)', () => {
|
|
const models = processModelsResponse(apiResponse);
|
|
assert.equal(models[0].id, 'preset-uuid',
|
|
'persona ID must use persona_id, not config_id:model_id');
|
|
});
|
|
|
|
it('falls back to persona_id when not present', () => {
|
|
const resp = {
|
|
models: [{
|
|
id: 'p-uuid', model_id: 'gpt-4o',
|
|
is_persona: 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');
|
|
});
|
|
|
|
it('marks as persona', () => {
|
|
const models = processModelsResponse(apiResponse);
|
|
assert.equal(models[0].isPersona, true);
|
|
});
|
|
|
|
it('preserves persona metadata', () => {
|
|
const models = processModelsResponse(apiResponse);
|
|
assert.equal(models[0].personaScope, 'global');
|
|
assert.equal(models[0].personaAvatar, '/avatars/code.png');
|
|
assert.equal(models[0].personaHandle, 'code-helper');
|
|
assert.equal(models[0].personaId, '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 (composite key)', () => {
|
|
const hidden = new Set(['c1: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('personas are never hidden via model ID', () => {
|
|
const resp = {
|
|
models: [
|
|
{ model_id: 'gpt-4o', is_persona: true, persona_id: 'p1', display_name: 'Persona' },
|
|
],
|
|
};
|
|
const hidden = new Set([':gpt-4o']);
|
|
const models = processModelsResponse(resp, hidden);
|
|
assert.equal(models[0].hidden, false,
|
|
'personas 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.isPersona && !b.isPersona) return -1;
|
|
if (!a.isPersona && b.isPersona) return 1;
|
|
if (a.isPersona && b.isPersona) {
|
|
const sa = scopeOrder[a.personaScope] ?? 9;
|
|
const sb = scopeOrder[b.personaScope] ?? 9;
|
|
if (sa !== sb) return sa - sb;
|
|
}
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
}
|
|
|
|
it('personas sort before regular models', () => {
|
|
const models = [
|
|
{ name: 'GPT-4o', isPersona: false },
|
|
{ name: 'Code Helper', isPersona: true, personaScope: 'global' },
|
|
];
|
|
const sorted = sortModels(models);
|
|
assert.equal(sorted[0].name, 'Code Helper');
|
|
assert.equal(sorted[1].name, 'GPT-4o');
|
|
});
|
|
|
|
it('global personas sort before team personas', () => {
|
|
const models = [
|
|
{ name: 'Team Bot', isPersona: true, personaScope: 'team' },
|
|
{ name: 'Global Bot', isPersona: true, personaScope: 'global' },
|
|
{ name: 'My Bot', isPersona: true, personaScope: 'personal' },
|
|
];
|
|
const sorted = sortModels(models);
|
|
assert.equal(sorted[0].personaScope, 'global');
|
|
assert.equal(sorted[1].personaScope, 'team');
|
|
assert.equal(sorted[2].personaScope, 'personal');
|
|
});
|
|
|
|
it('regular models sort alphabetically', () => {
|
|
const models = [
|
|
{ name: 'Zephyr', isPersona: false },
|
|
{ name: 'Claude', isPersona: false },
|
|
{ name: 'GPT-4o', isPersona: false },
|
|
];
|
|
const sorted = sortModels(models);
|
|
assert.equal(sorted[0].name, 'Claude');
|
|
assert.equal(sorted[1].name, 'GPT-4o');
|
|
assert.equal(sorted[2].name, 'Zephyr');
|
|
});
|
|
});
|