fix: add bindery-core unit tests for settings/translations, exclude templates from coverage

Agent-Logs-Url: https://github.com/evdboom/Bindery/sessions/9f85524a-bf21-46dc-9c6f-4d86ceab207e

Co-authored-by: evdboom <18037882+evdboom@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-04-28 05:15:20 +00:00 committed by GitHub
parent a747d5be6f
commit de2a2a6334
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 514 additions and 0 deletions

View file

@ -0,0 +1,218 @@
/**
* Unit tests for bindery-core/src/settings.ts
*
* Tests path helpers, readers, and accessors.
* Mirror of vscode-ext/test/workspace-extended.test.ts (settings subset).
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
getBinderyFolder,
getBookTitleForLang,
getDefaultLanguage,
getDialectsForLanguage,
getSettingsPath,
getTranslationsPath,
readWorkspaceSettings,
type WorkspaceSettings,
} from '../src/settings';
const tempRoots: string[] = [];
function makeRoot(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bindery-core-settings-test-'));
tempRoots.push(root);
return root;
}
function write(filePath: string, content: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf-8');
}
afterEach(() => {
for (const root of tempRoots.splice(0, tempRoots.length)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
// ─── Path helpers ─────────────────────────────────────────────────────────────
describe('path helpers', () => {
it('getBinderyFolder returns .bindery path', () => {
expect(getBinderyFolder('/root')).toBe(path.join('/root', '.bindery'));
});
it('getSettingsPath returns settings.json path', () => {
expect(getSettingsPath('/root')).toContain('settings.json');
});
it('getTranslationsPath returns translations.json path', () => {
expect(getTranslationsPath('/root')).toContain('translations.json');
});
});
// ─── readWorkspaceSettings ────────────────────────────────────────────────────
describe('readWorkspaceSettings', () => {
it('returns null when .bindery/settings.json does not exist', () => {
const root = makeRoot();
expect(readWorkspaceSettings(root)).toBeNull();
});
it('returns parsed settings when file exists', () => {
const root = makeRoot();
write(
path.join(root, '.bindery', 'settings.json'),
JSON.stringify({ bookTitle: 'Test', author: 'Alice', storyFolder: 'Story' })
);
const settings = readWorkspaceSettings(root);
expect(settings).not.toBeNull();
expect(settings!.bookTitle).toBe('Test');
expect(settings!.author).toBe('Alice');
});
it('returns null for malformed JSON', () => {
const root = makeRoot();
write(path.join(root, '.bindery', 'settings.json'), 'NOT JSON');
expect(readWorkspaceSettings(root)).toBeNull();
});
it('reads languages array from settings', () => {
const root = makeRoot();
write(
path.join(root, '.bindery', 'settings.json'),
JSON.stringify({
languages: [
{ code: 'EN', folderName: 'EN', chapterWord: 'Chapter', actPrefix: 'Act', prologueLabel: 'Prologue', epilogueLabel: 'Epilogue', isDefault: true },
{ code: 'NL', folderName: 'NL', chapterWord: 'Hoofdstuk', actPrefix: 'Deel', prologueLabel: 'Proloog', epilogueLabel: 'Epiloog' },
],
})
);
const settings = readWorkspaceSettings(root);
expect(settings!.languages).toHaveLength(2);
});
});
// ─── getBookTitleForLang ──────────────────────────────────────────────────────
describe('getBookTitleForLang', () => {
it('returns undefined for null settings', () => {
expect(getBookTitleForLang(null, 'en')).toBeUndefined();
});
it('returns undefined when bookTitle is absent', () => {
expect(getBookTitleForLang({}, 'en')).toBeUndefined();
});
it('returns string bookTitle directly', () => {
expect(getBookTitleForLang({ bookTitle: 'My Book' }, 'en')).toBe('My Book');
});
it('returns undefined for empty string bookTitle', () => {
expect(getBookTitleForLang({ bookTitle: '' }, 'en')).toBeUndefined();
});
it('resolves per-language title with exact match', () => {
const settings: WorkspaceSettings = { bookTitle: { en: 'Road', nl: 'Weg', fr: 'Route' } };
expect(getBookTitleForLang(settings, 'nl')).toBe('Weg');
expect(getBookTitleForLang(settings, 'fr')).toBe('Route');
});
it('falls back to en when specific language is not found', () => {
const settings: WorkspaceSettings = { bookTitle: { en: 'Road' } };
expect(getBookTitleForLang(settings, 'de')).toBe('Road');
});
it('returns undefined when map has no matching language and no en', () => {
const settings: WorkspaceSettings = { bookTitle: { fr: 'Route' } };
expect(getBookTitleForLang(settings, 'de')).toBeUndefined();
});
it('is case-insensitive for language codes', () => {
const settings: WorkspaceSettings = { bookTitle: { en: 'Road', nl: 'Weg' } };
expect(getBookTitleForLang(settings, 'NL')).toBe('Weg');
});
});
// ─── getDefaultLanguage ───────────────────────────────────────────────────────
describe('getDefaultLanguage', () => {
it('returns undefined for null settings', () => {
expect(getDefaultLanguage(null)).toBeUndefined();
});
it('returns undefined when languages is empty', () => {
expect(getDefaultLanguage({ languages: [] })).toBeUndefined();
});
it('returns undefined when languages is absent', () => {
expect(getDefaultLanguage({})).toBeUndefined();
});
it('returns the language marked isDefault', () => {
const settings: WorkspaceSettings = {
languages: [
{ code: 'NL', folderName: 'NL', chapterWord: 'H', actPrefix: 'D', prologueLabel: 'P', epilogueLabel: 'E' },
{ code: 'EN', folderName: 'EN', chapterWord: 'C', actPrefix: 'A', prologueLabel: 'P', epilogueLabel: 'E', isDefault: true },
],
};
expect(getDefaultLanguage(settings)!.code).toBe('EN');
});
it('returns first language when none marked as default', () => {
const settings: WorkspaceSettings = {
languages: [
{ code: 'NL', folderName: 'NL', chapterWord: 'H', actPrefix: 'D', prologueLabel: 'P', epilogueLabel: 'E' },
{ code: 'EN', folderName: 'EN', chapterWord: 'C', actPrefix: 'A', prologueLabel: 'P', epilogueLabel: 'E' },
],
};
expect(getDefaultLanguage(settings)!.code).toBe('NL');
});
});
// ─── getDialectsForLanguage ───────────────────────────────────────────────────
describe('getDialectsForLanguage', () => {
it('returns empty array for null settings', () => {
expect(getDialectsForLanguage(null, 'EN')).toEqual([]);
});
it('returns empty array when language has no dialects', () => {
const settings: WorkspaceSettings = {
languages: [
{ code: 'EN', folderName: 'EN', chapterWord: 'C', actPrefix: 'A', prologueLabel: 'P', epilogueLabel: 'E' },
],
};
expect(getDialectsForLanguage(settings, 'EN')).toEqual([]);
});
it('returns dialects for matching language (case-insensitive)', () => {
const settings: WorkspaceSettings = {
languages: [
{
code: 'EN', folderName: 'EN',
chapterWord: 'C', actPrefix: 'A', prologueLabel: 'P', epilogueLabel: 'E',
dialects: [{ code: 'en-gb', label: 'British English' }],
},
],
};
const dialects = getDialectsForLanguage(settings, 'en');
expect(dialects).toHaveLength(1);
expect(dialects[0].code).toBe('en-gb');
});
it('returns empty array for unmatched language code', () => {
const settings: WorkspaceSettings = {
languages: [
{ code: 'EN', folderName: 'EN', chapterWord: 'C', actPrefix: 'A', prologueLabel: 'P', epilogueLabel: 'E' },
],
};
expect(getDialectsForLanguage(settings, 'NL')).toEqual([]);
});
});

View file

@ -0,0 +1,291 @@
/**
* Unit tests for bindery-core/src/translations.ts
*
* Tests read/write helpers and all mutators.
* Mirror of vscode-ext/test/workspace.test.ts and workspace-extended.test.ts
* (translations subset).
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
addIgnoredWords,
getGlossaryRules,
getIgnoredWords,
getSubstitutionRules,
readTranslations,
upsertGlossaryRule,
upsertSubstitutionRule,
writeTranslations,
type TranslationsFile,
} from '../src/translations';
const tempRoots: string[] = [];
function makeRoot(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bindery-core-trans-test-'));
tempRoots.push(root);
return root;
}
function write(filePath: string, content: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf-8');
}
afterEach(() => {
for (const root of tempRoots.splice(0, tempRoots.length)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
// ─── readTranslations ─────────────────────────────────────────────────────────
describe('readTranslations', () => {
it('returns null when translations.json does not exist', () => {
const root = makeRoot();
expect(readTranslations(root)).toBeNull();
});
it('returns null for malformed JSON', () => {
const root = makeRoot();
write(path.join(root, '.bindery', 'translations.json'), '{{invalid');
expect(readTranslations(root)).toBeNull();
});
it('returns parsed translations when file exists', () => {
const root = makeRoot();
const data: TranslationsFile = {
'en-gb': { type: 'substitution', rules: [{ from: 'color', to: 'colour' }] },
};
write(path.join(root, '.bindery', 'translations.json'), JSON.stringify(data));
const translations = readTranslations(root);
expect(translations).not.toBeNull();
expect(translations!['en-gb'].rules![0].from).toBe('color');
});
});
// ─── writeTranslations ────────────────────────────────────────────────────────
describe('writeTranslations', () => {
it('creates .bindery directory and writes translations', () => {
const root = makeRoot();
const data: TranslationsFile = {
'en-gb': { type: 'substitution', rules: [{ from: 'a', to: 'b' }] },
};
writeTranslations(root, data);
const on_disk = readTranslations(root);
expect(on_disk).not.toBeNull();
expect(on_disk!['en-gb'].rules![0].from).toBe('a');
});
});
// ─── getSubstitutionRules ─────────────────────────────────────────────────────
describe('getSubstitutionRules', () => {
it('returns only substitution entries as replacement rules', () => {
const translations: TranslationsFile = {
'en-gb': { type: 'substitution', rules: [{ from: 'color', to: 'colour' }] },
nl: { type: 'glossary', rules: [{ from: 'FluxCore', to: 'FluxKern' }] },
};
expect(getSubstitutionRules(translations, 'en-gb')).toEqual([{ us: 'color', uk: 'colour' }]);
expect(getSubstitutionRules(translations, 'nl')).toEqual([]);
});
it('returns empty array for null translations', () => {
expect(getSubstitutionRules(null, 'en-gb')).toEqual([]);
});
it('resolves "uk" to en-gb entry (UK-like fallback)', () => {
const translations: TranslationsFile = {
'en-gb': { type: 'substitution', rules: [{ from: 'color', to: 'colour' }] },
};
expect(getSubstitutionRules(translations, 'uk')).toEqual([{ us: 'color', uk: 'colour' }]);
});
it('resolves "en-uk" to en-gb entry', () => {
const translations: TranslationsFile = {
'en-gb': { type: 'substitution', rules: [{ from: 'color', to: 'colour' }] },
};
expect(getSubstitutionRules(translations, 'en-uk')).toHaveLength(1);
});
it('direct match takes priority over UK-like fallback', () => {
const translations: TranslationsFile = {
uk: { type: 'substitution', rules: [{ from: 'a', to: 'b' }] },
'en-gb': { type: 'substitution', rules: [{ from: 'c', to: 'd' }] },
};
expect(getSubstitutionRules(translations, 'uk')[0].us).toBe('a');
});
});
// ─── getIgnoredWords ──────────────────────────────────────────────────────────
describe('getIgnoredWords', () => {
it('returns empty set for null translations', () => {
expect(getIgnoredWords(null, 'en-gb').size).toBe(0);
});
it('returns ignored words as a lowercase set', () => {
const translations: TranslationsFile = {
'en-gb': { type: 'substitution', ignoredWords: ['FluxCore', 'NETHER', ' padded '] },
};
const words = getIgnoredWords(translations, 'en-gb');
expect(words.has('fluxcore')).toBe(true);
expect(words.has('nether')).toBe(true);
expect(words.has('padded')).toBe(true);
expect(words.size).toBe(3);
});
it('skips empty/whitespace-only words', () => {
const translations: TranslationsFile = {
'en-gb': { type: 'substitution', ignoredWords: ['', ' ', 'valid'] },
};
const words = getIgnoredWords(translations, 'en-gb');
expect(words.size).toBe(1);
expect(words.has('valid')).toBe(true);
});
it('returns empty set for unknown language key', () => {
const translations: TranslationsFile = {
'en-gb': { type: 'substitution', ignoredWords: ['foo'] },
};
expect(getIgnoredWords(translations, 'nl').size).toBe(0);
});
});
// ─── addIgnoredWords ──────────────────────────────────────────────────────────
describe('addIgnoredWords', () => {
it('creates entry and adds words when no translations exist', () => {
const root = makeRoot();
const added = addIgnoredWords(root, 'en-gb', ['FluxCore', 'Nether']);
expect(added).toBe(2);
const translations = readTranslations(root);
expect(translations!['en-gb'].ignoredWords).toEqual(['fluxcore', 'nether']);
});
it('skips duplicate words (case-insensitive)', () => {
const root = makeRoot();
addIgnoredWords(root, 'en-gb', ['flux']);
const added = addIgnoredWords(root, 'en-gb', ['FLUX', 'new']);
expect(added).toBe(1);
});
it('skips empty/whitespace words', () => {
const root = makeRoot();
const added = addIgnoredWords(root, 'en-gb', ['', ' ', 'valid']);
expect(added).toBe(1);
});
it('preserves existing entry type and rules', () => {
const root = makeRoot();
upsertSubstitutionRule(root, 'en-gb', { from: 'color', to: 'colour' });
addIgnoredWords(root, 'en-gb', ['flux']);
const translations = readTranslations(root);
expect(translations!['en-gb'].type).toBe('substitution');
expect(translations!['en-gb'].rules).toHaveLength(1);
expect(translations!['en-gb'].ignoredWords).toContain('flux');
});
});
// ─── upsertSubstitutionRule ───────────────────────────────────────────────────
describe('upsertSubstitutionRule', () => {
it('upserts rules and keeps them sorted', () => {
const root = makeRoot();
upsertSubstitutionRule(root, 'en-gb', { from: 'color', to: 'colour' });
upsertSubstitutionRule(root, 'en-gb', { from: 'analyze', to: 'analyse' });
const translations = readTranslations(root);
const rules = translations!['en-gb'].rules ?? [];
expect(rules.map(r => r.from)).toEqual(['analyze', 'color']);
});
it('updates existing rule in-place', () => {
const root = makeRoot();
upsertSubstitutionRule(root, 'en-gb', { from: 'color', to: 'colour' });
upsertSubstitutionRule(root, 'en-gb', { from: 'color', to: 'coleur' });
const translations = readTranslations(root);
const rules = translations!['en-gb'].rules!;
expect(rules).toHaveLength(1);
expect(rules[0].to).toBe('coleur');
});
it('throws when entry type is glossary, not substitution', () => {
const root = makeRoot();
upsertGlossaryRule(root, 'nl', 'Dutch', 'en', { from: 'A', to: 'B' });
expect(() => {
upsertSubstitutionRule(root, 'nl', { from: 'color', to: 'colour' });
}).toThrow(/type 'glossary'/);
});
});
// ─── upsertGlossaryRule ───────────────────────────────────────────────────────
describe('upsertGlossaryRule', () => {
it('creates a glossary entry when none exists', () => {
const root = makeRoot();
upsertGlossaryRule(root, 'nl', 'Dutch', 'en', { from: 'FluxCore', to: 'FluxKern' });
const translations = readTranslations(root);
expect(translations!['nl'].type).toBe('glossary');
expect(translations!['nl'].label).toBe('Dutch');
expect(translations!['nl'].sourceLanguage).toBe('en');
expect(translations!['nl'].rules![0].from).toBe('FluxCore');
});
it('updates existing rule by case-insensitive from match', () => {
const root = makeRoot();
upsertGlossaryRule(root, 'nl', 'Dutch', 'en', { from: 'FluxCore', to: 'FluxKern' });
upsertGlossaryRule(root, 'nl', 'Dutch', 'en', { from: 'fluxcore', to: 'VluxKern' });
const translations = readTranslations(root);
expect(translations!['nl'].rules).toHaveLength(1);
expect(translations!['nl'].rules![0].to).toBe('VluxKern');
});
it('sorts rules alphabetically by from', () => {
const root = makeRoot();
upsertGlossaryRule(root, 'nl', 'Dutch', 'en', { from: 'Zeta', to: 'Z' });
upsertGlossaryRule(root, 'nl', 'Dutch', 'en', { from: 'Alpha', to: 'A' });
const translations = readTranslations(root);
expect(translations!['nl'].rules![0].from).toBe('Alpha');
expect(translations!['nl'].rules![1].from).toBe('Zeta');
});
});
// ─── getGlossaryRules ─────────────────────────────────────────────────────────
describe('getGlossaryRules', () => {
it('returns empty array for null translations', () => {
expect(getGlossaryRules(null, 'nl')).toEqual([]);
});
it('returns rules for glossary entries', () => {
const translations: TranslationsFile = {
nl: { type: 'glossary', rules: [{ from: 'Flux', to: 'Stroom' }] },
};
expect(getGlossaryRules(translations, 'nl')).toEqual([{ from: 'Flux', to: 'Stroom' }]);
});
it('filters out rules with empty from or to', () => {
const translations: TranslationsFile = {
nl: { type: 'glossary', rules: [
{ from: 'Good', to: 'Goed' },
{ from: '', to: 'empty' },
{ from: 'missing', to: '' },
] },
};
const rules = getGlossaryRules(translations, 'nl');
expect(rules).toHaveLength(1);
expect(rules[0].from).toBe('Good');
});
it('returns empty array for unknown key', () => {
const translations: TranslationsFile = {
nl: { type: 'glossary', rules: [{ from: 'A', to: 'B' }] },
};
expect(getGlossaryRules(translations, 'fr')).toEqual([]);
});
});

View file

@ -8,6 +8,11 @@ export default defineConfig({
reporter: ['text', 'text-summary', 'lcov', 'html'],
reportsDirectory: './coverage',
include: ['src/**/*.ts'],
exclude: [
'src/templates.ts', // large code-as-data aggregator; covered by mcp-ts tests
'src/templates/**', // individual template modules; covered by mcp-ts tests
'src/index.ts', // barrel re-export only
],
thresholds: {
statements: 80,
branches: 65,