// test/tag-matching-integration.test.ts
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { MarkdownManager } from '../src/services/markdown-manager.js';
import type { TimeEntry, CompanyConfig } from '../src/types/index.js';
/**
* Integration test for case-insensitive tag matching
*
* Tests the complete flow:
* 1. Write entries with mixed-case tags
* 2. Read them back from file
* 3. Verify tags are normalized for matching
* 4. Verify original capitalization is preserved in file
*/
describe('Tag Matching Integration', () => {
let testDir: string;
let originalEnv: string | undefined;
before(async () => {
// Create temporary directory for test files
testDir = await mkdtemp(join(tmpdir(), 'time-tracking-test-'));
// Set environment to use test directory
originalEnv = process.env.TIME_TRACKING_DIR;
process.env.TIME_TRACKING_DIR = testDir;
// Create test company config
const config: CompanyConfig = {
company: 'TestCompany',
commitments: {
total: { limit: 40, unit: 'hours/week' },
development: { limit: 30, unit: 'hours/week' },
meeting: { limit: 10, unit: 'hours/week' }
},
projects: {
'Frontend': {
tags: ['frontend', 'ui', 'react'],
commitment: 'development'
}
},
tagMappings: {
'dev': 'development',
'sync': 'meeting'
}
};
await writeFile(
join(testDir, 'config.json'),
JSON.stringify(config, null, 2)
);
});
after(async () => {
// Restore environment
if (originalEnv !== undefined) {
process.env.TIME_TRACKING_DIR = originalEnv;
} else {
delete process.env.TIME_TRACKING_DIR;
}
// Clean up test directory
await rm(testDir, { recursive: true, force: true });
});
it('should preserve capitalization in file but match case-insensitively', async () => {
const manager = new MarkdownManager();
// Use a unique week to avoid test interference
const entry1: TimeEntry = {
date: '2025-01-06', // Week 2
time: '14:30',
task: 'Code review',
duration: 2,
tags: ['Dev'] // Mixed case, maps to development
};
const entry2: TimeEntry = {
date: '2025-01-06', // Week 2
time: '16:00',
task: 'Meeting',
duration: 1,
tags: ['SYNC'] // Maps to meeting
};
const entry3: TimeEntry = {
date: '2025-01-06', // Week 2
time: '17:00',
task: 'More dev',
duration: 1.5,
tags: ['dev'] // Different case, should map to development
};
await manager.addEntry('default', entry1);
await manager.addEntry('default', entry2);
await manager.addEntry('default', entry3);
// Read back the summary from Week 2
const summary = await manager.getWeeklySummary('default', 2025, 2);
assert.ok(summary, 'Summary should exist');
// Verify commitments are aggregated correctly (case-insensitive)
// Both 'Dev' (2h) and 'dev' (1.5h) should map to 'development'
assert.equal(summary.byCommitment['development'], 3.5,
'Both Dev and dev should contribute to development commitment');
// 'SYNC' should map to 'meeting'
assert.equal(summary.byCommitment['meeting'], 1,
'SYNC should map to meeting commitment');
// Verify tags are normalized in summary
// Tags should be lowercase and aggregated
assert.equal(summary.byTag['dev'], 3.5,
'Dev and dev should aggregate under lowercase "dev"');
assert.equal(summary.byTag['sync'], 1,
'SYNC should be normalized to lowercase "sync"');
});
it('should handle tag mapping with mixed case in config keys', async () => {
const manager = new MarkdownManager();
// Create config with mixed-case mapping keys
const configPath = join(testDir, 'test-mixed-case', 'config.json');
await mkdir(join(testDir, 'test-mixed-case'), { recursive: true });
const config: CompanyConfig = {
company: 'MixedCase',
commitments: {
total: { limit: 40, unit: 'hours/week' },
development: { limit: 30, unit: 'hours/week' }
},
tagMappings: {
'Dev': 'development', // Mixed case in config
'SYNC': 'meeting' // Upper case in config
}
};
await writeFile(configPath, JSON.stringify(config, null, 2));
// Add entry with lowercase tags, use Week 3
const entry: TimeEntry = {
date: '2025-01-13', // Week 3
time: '14:30',
task: 'Development work',
duration: 2,
tags: ['dev', 'sync'] // Lowercase tags
};
// Set COMPANIES env var for multi-company mode
const prevCompanies = process.env.COMPANIES;
process.env.COMPANIES = 'test-mixed-case';
try {
await manager.addEntry('test-mixed-case', entry);
const summary = await manager.getWeeklySummary('test-mixed-case', 2025, 3);
assert.ok(summary, 'Summary should exist');
// Tags should still map correctly despite case mismatch
assert.equal(summary.byCommitment['development'], 2,
'Lowercase "dev" should map to development via mixed-case config key "Dev"');
} finally {
// Restore env
if (prevCompanies !== undefined) {
process.env.COMPANIES = prevCompanies;
} else {
delete process.env.COMPANIES;
}
}
});
it('should handle duplicate tags with different capitalization', async () => {
const manager = new MarkdownManager();
// User might accidentally provide duplicate tags with different cases
// Use Week 4 to avoid interference
const entry: TimeEntry = {
date: '2025-01-20', // Week 4
time: '14:30',
task: 'Task with duplicate tags',
duration: 2,
tags: ['dev', 'Dev', 'DEV', 'test'] // Duplicates with different cases
};
await manager.addEntry('default', entry);
const summary = await manager.getWeeklySummary('default', 2025, 4);
assert.ok(summary, 'Summary should exist');
// Fixed: Entry should only count once per commitment, regardless of duplicate tags
// Even though entry has ['dev', 'Dev', 'DEV'] (3 tags), all map to 'development'
// So it should count as 2h once, not 6h (3 × 2h)
assert.equal(summary.byCommitment['development'], 2,
'Entry counts once per commitment even with duplicate tags mapping to same commitment');
// Tags are still tracked separately in byTag (for tag statistics)
// All three 'dev' variants aggregate under lowercase 'dev'
assert.equal(summary.byTag['dev'], 6,
'All dev variants aggregate under lowercase "dev" in tag statistics (3 instances × 2h)');
assert.equal(summary.byTag['test'], 2,
'Non-duplicate tag counts once');
});
});