import assert from 'node:assert/strict' import { test } from 'node:test' import { Config, configValue, settingsSnapshot, toSlopoConfig, workspacePaths } from '../index.js' import { SLOPO_CONFIG_KEYS, missingRequired, normalizeConfig, pruneConfig, renderSlopoConfig, } from '../lib/config.js' test('pruneConfig keeps only known keys and drops empties', () => { const pruned = pruneConfig({ source_dir: '/src', embedding_model: '', embedding_dimensions: 0, source_dir_exclude: [], unknown_key: 'nope', embedding_params: {}, }) assert.deepEqual(Object.keys(pruned), ['source_dir', 'embedding_dimensions']) assert.equal(pruned.source_dir, '/src') // 0 is a real value, not an empty one. assert.equal(pruned.embedding_dimensions, 0) }) test('pruneConfig silently refuses a key slopo would hard-error on', () => { const pruned = pruneConfig({ source_dir: '/src', totally_unknown: 'x' }) assert.equal(Object.hasOwn(pruned, 'totally_unknown'), false) for (const key of Object.keys(pruned)) assert.ok(SLOPO_CONFIG_KEYS.includes(key)) }) test('renderSlopoConfig emits key: value with a space, which slopo lints for', () => { const text = renderSlopoConfig({ source_dir: '/a b/c', embedding_dimensions: 768 }) assert.match(text, /^source_dir: "\/a b\/c"$/m) assert.match(text, /^embedding_dimensions: 768$/m) for (const line of text.split('\n')) { if (line === '' || line.startsWith('#') || line.startsWith(' ')) continue assert.doesNotMatch(line, /^[A-Za-z_][A-Za-z0-9_]*:\S/, `no-space-after-colon: ${line}`) } }) test('renderSlopoConfig always quotes strings so a colon or dash cannot change meaning', () => { const text = renderSlopoConfig({ embedding_model: 'ollama/model:latest', db_file: '/tmp/-weird#path/x.db', }) assert.match(text, /^embedding_model: "ollama\/model:latest"$/m) assert.match(text, /^db_file: "\/tmp\/-weird#path\/x\.db"$/m) }) test('renderSlopoConfig escapes a quote rather than emitting broken YAML', () => { const text = renderSlopoConfig({ source_dir: '/a"b' }) assert.match(text, /^source_dir: "\/a\\"b"$/m) }) test('renderSlopoConfig renders lists and nested maps', () => { const text = renderSlopoConfig({ source_dir_exclude: ['**/test/**', '*.min.js'], embedding_params: { api_base: 'http://127.0.0.1:11434' }, }) assert.match(text, /^source_dir_exclude:\n {2}- "\*\*\/test\/\*\*"\n {2}- "\*\.min\.js"$/m) assert.match(text, /^embedding_params:\n {2}api_base: "http:\/\/127\.0\.0\.1:11434"$/m) }) test('renderSlopoConfig never writes the embedding API key', () => { const text = renderSlopoConfig({ source_dir: '/src', embedding_api_key: 'sk-secret' }) assert.doesNotMatch(text, /sk-secret/) assert.doesNotMatch(text, /embedding_api_key/) }) test('renderSlopoConfig refuses to silently write a non-finite number', () => { assert.throws(() => renderSlopoConfig({ embedding_dimensions: Number.NaN }), TypeError) }) test('missingRequired reports exactly the three keys slopo refuses to start without', () => { assert.deepEqual(missingRequired({}), ['source_dir', 'embedding_model', 'embedding_dimensions']) assert.deepEqual(missingRequired({ source_dir: '/src', embedding_model: 'm', embedding_dimensions: 768 }), []) // A dimension of 0 counts as unset, not as configured. assert.deepEqual(missingRequired({ source_dir: '/s', embedding_model: 'm', embedding_dimensions: 0 }), [ 'embedding_dimensions', ]) }) test('normalizeConfig coerces numeric strings from a settings form', () => { const { config, problems } = normalizeConfig({ embedding_dimensions: '768', embedding_batch_size: '50' }) assert.deepEqual(problems, []) assert.equal(config.embedding_dimensions, 768) assert.equal(config.embedding_batch_size, 50) }) test('normalizeConfig rejects a non-numeric value instead of writing a corrupt config', () => { const { config, problems } = normalizeConfig({ embedding_dimensions: 'lots' }) assert.equal(Object.hasOwn(config, 'embedding_dimensions'), false) assert.equal(problems.length, 1) assert.match(problems[0], /embedding_dimensions must be a number/) }) test('normalizeConfig keeps thresholds fractional but rejects fractional counts', () => { const ok = normalizeConfig({ review_similarity_threshold: 0.92 }) assert.equal(ok.config.review_similarity_threshold, 0.92) assert.deepEqual(ok.problems, []) const bad = normalizeConfig({ embedding_batch_size: 12.5 }) assert.equal(Object.hasOwn(bad.config, 'embedding_batch_size'), false) assert.match(bad.problems[0], /whole number/) }) /** * The other half of the config contract: the shape a section ARRIVES in. * * Every field of `Config` is `volatile()`, so Cordis hands `apply` a reference * object per field rather than a value — that is what lets a settings edit land * without re-applying the plugin. A plain-value read of such a field is silently * false, which is how this plugin came to ignore its own settings and fall back * to autodiscovery. These tests resolve through the real schema path, because a * fixture built from plain literals can never catch that. */ /** The section as the Loader builds it: schema-resolved, references intact. */ function resolved(patch = {}) { const result = Config['~standard'].validate(patch) assert.equal(result.issues, undefined, `the schema refused the fixture: ${JSON.stringify(result.issues)}`) return result.value } test('a volatile field arrives as a reference, and configValue unwraps it', () => { const config = resolved({ embeddingApiBase: 'http://192.0.2.10:11434' }) assert.equal(typeof config.embeddingApiBase, 'object', 'a volatile field is a reference, not a value') assert.equal(configValue(config.embeddingApiBase), 'http://192.0.2.10:11434') // …and a plain value is handed straight back, so a caller that passes a // literal object — every other test in this suite, and `apply` in a profile // whose fields are not volatile — takes the very same code path. assert.equal(configValue('http://plain'), 'http://plain') assert.equal(configValue(undefined), undefined) assert.equal(configValue(0), 0) }) test('a snapshot reads the configured values out of a schema-resolved section', () => { const settings = settingsSnapshot( resolved({ embeddingModel: 'ollama/example:latest', embeddingDimensions: 768, embeddingRequestDelay: 25, embeddingParamsJson: '{"keep_alive":"5m"}', }), ) assert.equal(settings.embeddingModel, 'ollama/example:latest') assert.equal(settings.embeddingDimensions, 768) assert.equal(settings.embeddingRequestDelay, 25) assert.equal(settings.embeddingParamsJson, '{"keep_alive":"5m"}') // The fields left unset are still the schema's own defaults, not `undefined`. assert.equal(settings.sourceDir, '') assert.deepEqual(settings.sourceDirExclude, []) }) test('toSlopoConfig turns a schema-resolved section into slopo config, not into defaults', () => { // The regression: every read in this path was a plain-value check, so the // model read as '' and the dimensions as undefined — the tool reported itself // unconfigured while the settings card was displaying a configured one. const config = resolved({ embeddingModel: 'ollama/example:latest', embeddingDimensions: 768, embeddingRequestDelay: 25, embeddingApiBase: 'http://192.0.2.10:11434', embeddingParamsJson: '{"keep_alive":"5m"}', sourceDir: '/src', }) const paths = workspacePaths('/home/dsh', '/work', config) assert.equal(paths.sourceDir, '/src') const problems = [] const slopoConfig = toSlopoConfig(config, paths, problems) assert.deepEqual(problems, []) assert.equal(slopoConfig.embedding_model, 'ollama/example:latest') assert.equal(slopoConfig.embedding_dimensions, 768) assert.equal(slopoConfig.embedding_request_delay, 25) assert.deepEqual(slopoConfig.embedding_params, { keep_alive: '5m', api_base: 'http://192.0.2.10:11434' }) }) test('the same helpers still take a plain object, which is how they are called elsewhere', () => { const paths = workspacePaths('/home/dsh', '/work', { sourceDir: '/src', stateDir: '/state' }) assert.equal(paths.stateDir, '/state') assert.equal(paths.sourceDir, '/src') const slopoConfig = toSlopoConfig({ embeddingModel: 'ollama/plain:latest', embeddingDimensions: 1024 }, paths) assert.equal(slopoConfig.embedding_model, 'ollama/plain:latest') assert.equal(slopoConfig.embedding_dimensions, 1024) }) test('a snapshot taken per call sees a live edit, because the reference is written in place', () => { const config = resolved({ embeddingDimensions: 768 }) assert.equal(settingsSnapshot(config).embeddingDimensions, 768) // This is what the Loader does on a volatile-only settings change: it writes // the new value into the reference the running instance already holds, without // re-applying the plugin. A snapshot cached at apply time would never see it, // which is why the tool takes a fresh one per call. const [write] = Object.getOwnPropertySymbols(config.embeddingDimensions) config.embeddingDimensions[write](1024) assert.equal(settingsSnapshot(config).embeddingDimensions, 1024) })