Introduce a liveGroupingScoring engine that combines existing membership signals (normalized name, brand, number, metadata tokens, language, category/source when relevant) with a bounded Laya contribution: Laya raises or lowers confidence instead of acting as a final judge, with guard-rails against obvious contradictions. Sanitize Laya batch identity inputs in layaService and wire the weighted scoring into the live projection service. Weights are not exposed in the Nodecast UI. Cover signal combination, bounds and contradiction guard-rails with tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
143 lines
5.8 KiB
JavaScript
143 lines
5.8 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.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);
|
|
});
|
|
|
|
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('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/);
|
|
});
|