fix: track Laya language batches and normalize labels (#22)

Report post-projection Laya language detection as a busy analysis phase and normalize Laya language labels before caching and display.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-05 23:42:11 +02:00
parent 45b56d872f
commit a6759b116d
9 changed files with 282 additions and 21 deletions

View File

@ -2,6 +2,12 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const fusion = require('../public/js/services/channelLanguageFusion.js');
test('normalizes common Laya language labels to supported codes', () => {
assert.equal(fusion.normalizeLanguageCode('French'), 'fr');
assert.equal(fusion.normalizeLanguageCode('English'), 'en');
assert.equal(fusion.normalizeLanguageCode('ESPAÑOL'), 'es');
});
const algo = {
language: { code: 'fr', label: 'French', confidence: 70, status: 'medium' },
languageScores: { fr: 70, en: 30 },

View File

@ -152,8 +152,8 @@ test('language batch sends the Laya contract and parses language confidence with
ok: true,
json: async () => ({
results: [
{ answers: { language: { choice: 'fr', answer_confidence: 0.93 } } },
{ answers: { language: { choice: 'en', confidence: 0.81 } } }
{ answers: { language: { choice: 'French', answer_confidence: 0.93 } } },
{ answers: { language: { choice: 'English', confidence: 0.81 } } }
]
})
};
@ -181,6 +181,45 @@ test('language batch sends the Laya contract and parses language confidence with
});
});
test('language batch preserves explicit other and maps an unfamiliar label to unknown', async () => {
const service = new LayaService({
cache: null,
fetch: async () => ({
ok: true,
json: async () => ({
results: [
{ answers: { language: { choice: 'Other', confidence: 0.72 } } },
{ answers: { language: { choice: 'Klingon', confidence: 0.99 } } }
]
})
})
});
const response = await service.detectBatch({
items: [{ id: 'other', text: 'Autre' }, { id: 'strange', text: 'Qapla' }]
});
assert.deepEqual(response.results, [
{ id: 'other', language: 'other', confidence: 0.72, cached: false },
{ id: 'strange', language: 'unknown', confidence: 0.99, cached: false }
]);
});
test('language batch rejects a successful HTTP response without a usable language answer', async () => {
const service = new LayaService({
cache: null,
fetch: async () => ({
ok: true,
json: async () => ({ results: [{ answers: { language: { confidence: 0.9 } } }] })
})
});
await assert.rejects(
service.detectBatch({ items: [{ id: 'missing', text: 'Missing language' }] }),
error => error instanceof LayaRequestError && error.statusCode === 502
);
});
test('identity hints send only sanitized channel metadata to Laya', async () => {
let requestBody;
const service = new LayaService({

View File

@ -26,6 +26,19 @@ test('presents Laya identity progress using AI batch counters', () => {
assert.equal(view.progressText, 'AI batch 3 of 8 · 38%');
});
test('presents local Laya language batch progress after projection', () => {
const view = present({
status: 'building',
phase: 'laya_language',
languageProcessed: 5,
languageTotal: 10
});
assert.equal(view.statusLabel, 'Detecting stream languages…');
assert.equal(view.progressIndeterminate, false);
assert.equal(view.progressPercent, 50);
assert.equal(view.progressText, 'Language batch 5 of 10 · 50%');
});
test('presents indeterminate progress when totals are unavailable', () => {
const view = present({ status: 'queued', processed: null, total: null });
assert.equal(view.busy, true);

View File

@ -92,6 +92,18 @@ function projectedChannels() {
};
}
function waitFor(condition, message) {
return new Promise((resolve, reject) => {
const started = Date.now();
const poll = () => {
if (condition()) return resolve();
if (Date.now() - started > 1500) return reject(new Error(message));
setTimeout(poll, 5);
};
poll();
});
}
test('Settings renders Laya batch progress and removes misleading values when its total is unknown', () => {
const { window } = createDom();
const settingsPage = Object.create(window.SettingsPage.prototype);
@ -129,6 +141,67 @@ test('Settings renders Laya batch progress and removes misleading values when it
assert.equal(bar.style.width, '');
});
test('analysis remains visibly busy while Laya language batches run after projection completes', async () => {
const { window } = createDom();
let rebuildClicked = false;
let languageRefreshStarted = false;
let releaseLanguageRefresh;
const languageRefresh = new Promise(resolve => { releaseLanguageRefresh = resolve; });
window.API = {
live: {
getProjectionStatus: async () => rebuildClicked
? { status: 'success', phase: 'complete', processed: 128, total: 128, projectedCount: 64 }
: { status: 'idle', processed: 0, total: 128 },
rebuildProjection: async () => {
rebuildClicked = true;
return {
projection: {
status: 'building',
phase: 'laya_identity',
layaProcessed: 4,
layaTotal: 4,
processed: 0,
total: 128
}
};
}
},
laya: {
getStatus: async () => ({ modelPresent: true, runtimeAvailable: true })
}
};
const app = {
player: {
settings: { languageDetectionMode: 'laya' },
saveSettings() {}
},
channelList: {
loadChannels: async () => {},
refreshLayaLanguages: async () => {
languageRefreshStarted = true;
await languageRefresh;
}
}
};
window.app = app;
const settingsPage = Object.create(window.SettingsPage.prototype);
settingsPage.app = app;
settingsPage.layaStatus = { modelPresent: true, runtimeAvailable: true };
settingsPage.initLiveAnalysis();
await Promise.resolve();
window.document.getElementById('live-analysis-button').click();
try {
await waitFor(() => languageRefreshStarted, 'Expected Laya language refresh to start after projection completion');
assert.notEqual(window.document.getElementById('live-analysis-status').textContent, 'Analysis complete');
assert.equal(window.document.getElementById('live-analysis-progress').hidden, false);
} finally {
releaseLanguageRefresh();
window.clearInterval(settingsPage.liveAnalysisPollTimer);
}
});
test('user analysis click keeps visible channels after Local AI language failure', async () => {
const { window } = createDom();
let rebuildClicked = 0;
@ -348,6 +421,53 @@ test('successful Laya language batch updates visible variant languages without S
assert.equal(window.document.querySelector('.laya-language-notice'), null);
});
test('usable Laya language labels do not leave visible variants as unknown', async () => {
const { window } = createDom();
window.API = {
live: {
getChannels: async () => projectedChannels()
},
laya: {
getStatus: async () => ({ modelPresent: true, runtimeAvailable: true }),
detectLanguages: async items => ({
runtimeAvailable: true,
model: 'multilingual',
results: items.map(item => ({
id: item.id,
language: item.id.endsWith('fr-sd') ? 'French' : 'English',
confidence: 0.91
}))
})
}
};
const app = {
player: {
settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'laya' },
saveSettings() {}
},
epgGuide: null,
navigateTo() {},
pages: { settings: { switchTab() {} } }
};
window.app = app;
const channelList = new window.ChannelList();
channelList.sources = [{ id: 1, type: 'm3u', name: 'Fixture Provider' }];
app.channelList = channelList;
await channelList.loadChannels();
await channelList.refreshLayaLanguages('laya');
channelList.expandAll();
const badges = [...window.document.querySelectorAll('.variant-language')].map(item => item.textContent);
window.clearInterval(channelList._epgRefreshTimer);
assert.deepEqual([...new Set(badges)].sort(), ['EN 91%', 'FR 91%']);
assert.equal(channelList.languageDetectionFallback, false);
assert.equal(channelList.languageDetectionNotice, '');
});
test('analysis click does not replace visible channels with an empty successful projection', async () => {
const { window } = createDom();
let rebuildClicked = 0;