/** * Tests for utils/log-helpers.js * * The log panel filters and dedups thousands of log entries during a * research run. These helpers power that — an off-by-one in * checkLogVisibility means warnings disappear; a collision in * hashString means dedupe drops real entries. */ import '@js/utils/log-helpers.js'; const { checkLogVisibility, emptyCounts, hashString, normalizeMessage, normalizeTimestamps, } = window.LdrLogHelpers; describe('checkLogVisibility', () => { it("filter 'all' shows every type", () => { for (const type of ['info', 'milestone', 'warning', 'error']) { expect(checkLogVisibility(type, 'all')).toBe(true); } }); it("filter 'info' only shows info entries (regression: was a no-op)", () => { // The pre-fix implementation returned true for every type under // the 'info' filter, which made clicking the Info filter button // effectively a no-op. This test pins down the correct, narrow // behaviour: only entries whose type is 'info' are visible. expect(checkLogVisibility('info', 'info')).toBe(true); expect(checkLogVisibility('milestone', 'info')).toBe(false); expect(checkLogVisibility('warning', 'info')).toBe(false); expect(checkLogVisibility('error', 'info')).toBe(false); }); it("filter 'milestone' only shows milestones", () => { expect(checkLogVisibility('milestone', 'milestone')).toBe(true); expect(checkLogVisibility('info', 'milestone')).toBe(false); expect(checkLogVisibility('warning', 'milestone')).toBe(false); expect(checkLogVisibility('error', 'milestone')).toBe(false); }); it("accepts plural 'milestones' as an alias", () => { expect(checkLogVisibility('milestone', 'milestones')).toBe(true); expect(checkLogVisibility('info', 'milestones')).toBe(false); }); it("filter 'warning' shows warnings and errors", () => { expect(checkLogVisibility('warning', 'warning')).toBe(true); expect(checkLogVisibility('error', 'warning')).toBe(true); expect(checkLogVisibility('info', 'warning')).toBe(false); expect(checkLogVisibility('milestone', 'warning')).toBe(false); }); it("accepts plural 'warnings' as an alias", () => { expect(checkLogVisibility('warning', 'warnings')).toBe(true); expect(checkLogVisibility('error', 'warnings')).toBe(true); expect(checkLogVisibility('info', 'warnings')).toBe(false); }); it("filter 'error' only shows errors", () => { expect(checkLogVisibility('error', 'error')).toBe(true); expect(checkLogVisibility('warning', 'error')).toBe(false); expect(checkLogVisibility('info', 'error')).toBe(false); expect(checkLogVisibility('milestone', 'error')).toBe(false); }); it("accepts plural 'errors' as an alias", () => { expect(checkLogVisibility('error', 'errors')).toBe(true); expect(checkLogVisibility('warning', 'errors')).toBe(false); }); it('unknown filter defaults to showing everything', () => { expect(checkLogVisibility('info', 'bogus')).toBe(true); expect(checkLogVisibility('error', '')).toBe(true); expect(checkLogVisibility('milestone', undefined)).toBe(true); }); }); describe('emptyCounts', () => { it('returns a fresh object on each call (mutations do not leak)', () => { const a = emptyCounts(); const b = emptyCounts(); expect(a).not.toBe(b); a.info = 99; expect(b.info).toBe(0); }); it('initialises every category to zero with the expected keys', () => { const counts = emptyCounts(); expect(counts).toEqual({ info: 0, milestone: 0, warning: 0, error: 0, }); }); }); describe('hashString', () => { it('returns "0" for empty/null/undefined', () => { expect(hashString('')).toBe('0'); expect(hashString(null)).toBe('0'); expect(hashString(undefined)).toBe('0'); }); it('is deterministic: same input → same output', () => { const a = hashString('log entry one'); const b = hashString('log entry one'); expect(a).toBe(b); }); it('produces different hashes for different inputs', () => { expect(hashString('log entry one')).not.toBe(hashString('log entry two')); expect(hashString('a')).not.toBe(hashString('b')); }); it('handles long strings', () => { const long = 'x'.repeat(10000); expect(hashString(long)).toMatch(/^-?\d+$/); }); it('handles unicode characters', () => { // Should not throw and should differ from its ASCII analogue expect(hashString('café')).toMatch(/^-?\d+$/); expect(hashString('café')).not.toBe(hashString('cafe')); }); }); describe('normalizeMessage', () => { it('returns empty string for falsy input', () => { expect(normalizeMessage('')).toBe(''); expect(normalizeMessage(null)).toBe(''); expect(normalizeMessage(undefined)).toBe(''); }); it('trims whitespace', () => { expect(normalizeMessage(' hello ')).toBe('hello'); expect(normalizeMessage('\t\nhello\r\n')).toBe('hello'); }); it('lowercases', () => { expect(normalizeMessage('HELLO World')).toBe('hello world'); }); it('trims and lowercases in combination', () => { expect(normalizeMessage(' HELLO ')).toBe('hello'); }); }); describe('normalizeTimestamps', () => { it('is a no-op when all logs share the same date', () => { const logs = [ {time: '2026-04-01T10:00:00Z', message: 'a'}, {time: '2026-04-01T11:00:00Z', message: 'b'}, {time: '2026-04-01T12:00:00Z', message: 'c'}, ]; const before = JSON.parse(JSON.stringify(logs)); normalizeTimestamps(logs); expect(logs).toEqual(before); }); it('re-stamps an outlier date to the majority date', () => { const logs = [ {time: '2026-04-01T10:00:00.000Z', message: 'a'}, {time: '2026-04-01T11:00:00.000Z', message: 'b'}, {time: '2026-04-01T12:00:00.000Z', message: 'c'}, {time: '2026-04-02T15:30:00.000Z', message: 'outlier'}, ]; const originalTime = logs[3].time; normalizeTimestamps(logs); const outlier = logs[3]; // The outlier's date portion should now be 2026-04-01 expect(outlier.time.startsWith('2026-04-01')).toBe(true); // The time should have changed from the original expect(outlier.time).not.toBe(originalTime); // ID should be regenerated from the new timestamp + hash expect(outlier.id).toMatch(/^2026-04-01.*-/); }); it('does nothing when logs is empty', () => { const logs = []; expect(() => normalizeTimestamps(logs)).not.toThrow(); expect(logs).toEqual([]); }); it('does nothing when every log has an unparseable time', () => { const logs = [ {time: 'not-a-date', message: 'a'}, {time: 'also-not-a-date', message: 'b'}, ]; // Should not throw; mostCommonDate would remain null expect(() => normalizeTimestamps(logs)).not.toThrow(); }); it('handles a tie by picking one date deterministically (first seen wins via iteration order)', () => { // Two days with equal count — either is acceptable, just shouldn't crash const logs = [ {time: '2026-04-01T10:00:00Z', message: 'a'}, {time: '2026-04-02T10:00:00Z', message: 'b'}, ]; expect(() => normalizeTimestamps(logs)).not.toThrow(); // Both should end up on the same day const d1 = logs[0].time.split('T')[0]; const d2 = logs[1].time.split('T')[0]; expect(d1).toBe(d2); }); });