Files
Nodecast/test/layaService.test.js
blomios a6759b116d 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>
2026-10-05 23:42:11 +02:00

243 lines
9.4 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs/promises');
const os = require('os');
const path = require('path');
const {
LayaRequestError,
LayaService,
clearDirectoryContents,
validateBatchPayload
} = require('../server/services/layaService');
test('status reports an unavailable runtime without throwing', async () => {
const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-status-'));
const service = new LayaService({
modelDir,
cache: null,
fetch: async () => { throw new Error('connection refused'); }
});
const status = await service.status();
assert.equal(status.runtimeAvailable, false);
assert.equal(status.ready, false);
assert.equal(status.modelPresent, false);
assert.equal(status.phase, 'idle');
assert.equal(status.downloading, false);
assert.equal(status.progressPercent, null);
assert.equal(status.bytesDownloaded, null);
assert.equal(status.totalBytes, null);
assert.equal(status.sizeBytes, 0);
assert.equal(status.error, null);
assert.match(status.runtimeError.detail, /Start the Laya sidecar/);
});
test('status does not report ready when a model exists but the runtime is unavailable', async () => {
const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-offline-model-'));
await fs.writeFile(path.join(modelDir, 'model.bin'), 'model');
const service = new LayaService({
modelDir,
cache: null,
fetch: async () => { throw new Error('connection refused'); }
});
const status = await service.status();
assert.equal(status.modelPresent, true);
assert.equal(status.runtimeAvailable, false);
assert.equal(status.ready, false);
assert.equal(status.runtimeError.message, 'Laya runtime is unavailable');
});
test('model download exposes an indeterminate transition and finishes ready', async () => {
const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-download-'));
let finishDownload;
const downloadResponse = new Promise(resolve => { finishDownload = resolve; });
const service = new LayaService({
modelDir,
cache: null,
progressIntervalMs: 5,
fetch: async url => {
if (url.endsWith('/health')) return { ok: true, json: async () => ({ status: 'ok' }) };
await downloadResponse;
return { ok: true, json: async () => ({ answers: {} }) };
}
});
const started = service.startModelDownload();
assert.equal(started.phase, 'preparing');
assert.equal(started.downloading, true);
assert.equal(started.progressPercent, null);
await fs.writeFile(path.join(modelDir, 'partial.bin'), 'partial model');
await new Promise(resolve => setTimeout(resolve, 15));
const downloading = await service.status();
assert.equal(downloading.phase, 'downloading');
assert.equal(downloading.downloading, true);
assert.ok(downloading.bytesDownloaded > 0);
finishDownload();
await service.downloadPromise;
const ready = await service.status();
assert.equal(ready.phase, 'ready');
assert.equal(ready.downloading, false);
assert.equal(ready.modelPresent, true);
assert.equal(ready.error, null);
});
test('download failures remain visible in status and retry resets the error', async () => {
const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-failure-'));
let shouldFail = true;
const service = new LayaService({
modelDir,
cache: null,
fetch: async url => {
if (url.endsWith('/health')) throw new Error('connection refused at https://secret.invalid/token');
if (shouldFail) throw new Error('download failed at https://secret.invalid/token');
return { ok: true, json: async () => ({ answers: {} }) };
}
});
service.startModelDownload();
await service.downloadPromise;
const failed = await service.status();
assert.equal(failed.phase, 'failed');
assert.equal(failed.downloading, false);
assert.equal(failed.error.message, 'Laya runtime is unavailable');
assert.doesNotMatch(JSON.stringify(failed), /secret\.invalid|token/);
shouldFail = false;
const retry = service.startModelDownload();
assert.equal(retry.error, null);
assert.equal(retry.lastError, null);
await service.downloadPromise;
});
test('model deletion remains limited to the configured model directory', async () => {
const parent = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-delete-'));
const modelDir = path.join(parent, 'models');
const sentinel = path.join(parent, 'keep.txt');
await fs.mkdir(path.join(modelDir, 'nested'), { recursive: true });
await fs.writeFile(path.join(modelDir, 'nested', 'model.bin'), 'model');
await fs.writeFile(sentinel, 'keep');
const service = new LayaService({ modelDir, cache: null, fetch: async () => { throw new Error('offline'); } });
service.downloadState.phase = 'failed';
service.downloadState.error = { message: 'previous error', detail: 'safe' };
const deleted = await service.deleteModel();
assert.deepEqual(await fs.readdir(modelDir), []);
assert.equal(await fs.readFile(sentinel, 'utf8'), 'keep');
assert.equal(deleted.phase, 'idle');
assert.equal(deleted.error, null);
assert.equal(deleted.sizeBytes, 0);
});
test('batch validation rejects oversized item counts', () => {
assert.throws(
() => validateBatchPayload({ items: Array.from({ length: 65 }, (_, id) => ({ id, text: 'channel' })) }),
error => error instanceof LayaRequestError && error.statusCode === 413
);
});
test('language batch sends the Laya contract and parses language confidence without fallback', async () => {
let requestedUrl;
let requestBody;
const service = new LayaService({
cache: null,
batchTimeoutMs: 120000,
fetch: async (url, options) => {
requestedUrl = url;
requestBody = JSON.parse(options.body);
return {
ok: true,
json: async () => ({
results: [
{ answers: { language: { choice: 'French', answer_confidence: 0.93 } } },
{ answers: { language: { choice: 'English', confidence: 0.81 } } }
]
})
};
}
});
const response = await service.detectBatch({
model: 'multilingual',
items: [
{ id: 'fr-channel', text: 'France 2 FR' },
{ id: 'en-channel', text: 'BBC News UK' }
]
});
assert.equal(requestedUrl, 'http://laya:8000/v1/systemone/batch');
assert.deepEqual(requestBody.states, ['France 2 FR', 'BBC News UK']);
assert.equal(requestBody.model, 'multilingual');
assert.deepEqual(response, {
runtimeAvailable: true,
model: 'multilingual',
results: [
{ id: 'fr-channel', language: 'fr', confidence: 0.93, cached: false },
{ id: 'en-channel', language: 'en', confidence: 0.81, cached: false }
]
});
});
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({
cache: null,
fetch: async (url, options) => {
requestBody = JSON.parse(options.body);
return {
ok: true,
json: async () => ({ results: [{ answers: { sameIdentity: { noul: 0.9, confidence: 0.8 } } }] })
};
}
});
await service.identityHintBatch([{
id: 'pair',
left: { name: 'Sports http://user:pass@example.test/live', group: 'token=secret', source: 'Provider', tvgId: 'one', streamUrl: 'forbidden' },
right: { name: 'Sports', group: 'Group', source: 'Provider', tvgId: 'two', password: 'forbidden' }
}]);
assert.deepEqual(Object.keys(requestBody.states[0].left), ['name', 'group', 'source', 'tvgId']);
assert.doesNotMatch(JSON.stringify(requestBody), /example\.test|secret|forbidden|user:pass/);
});