diff --git a/.gitignore b/.gitignore index 43c633a..dd05106 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ /build /webui/dist +# test fixtures +*.temp.jsonl + # dependencies node_modules/ npm-debug.log* diff --git a/src/data/StatsFileSource.ts b/src/data/StatsFileSource.ts index 39bf802..a003a48 100644 --- a/src/data/StatsFileSource.ts +++ b/src/data/StatsFileSource.ts @@ -1,7 +1,7 @@ import assert from 'assert'; import type { PartialStatsEntry, StatsEntry, StatsSource } from './types'; -import { appendJsonLine, forEachJsonLines, parseJsonLine } from '../utils/ndjson'; +import { appendJsonLine, forEachJsonLines, parseJsonLine } from '../utils/jsonl'; export class StatsFileSource implements StatsSource { constructor(public readonly statsPath: string) { diff --git a/src/utils/__tests__/fixtures/ndjson.json b/src/utils/__tests__/fixtures/specification.jsonl similarity index 100% rename from src/utils/__tests__/fixtures/ndjson.json rename to src/utils/__tests__/fixtures/specification.jsonl diff --git a/src/utils/__tests__/jsonl.test.ts b/src/utils/__tests__/jsonl.test.ts new file mode 100644 index 0000000..448909d --- /dev/null +++ b/src/utils/__tests__/jsonl.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, mock } from 'bun:test'; +import fs from 'fs'; +import path from 'path'; + +import { appendJsonLine, forEachJsonLines, parseJsonLine } from '../jsonl'; + +describe('forEachJsonLines', () => { + it('iterates each line of file', async () => { + const lines: string[] = []; + await forEachJsonLines(fixture('specification'), (content) => { + lines.push(content); + }); + + expect(lines).toEqual([ + expect.stringContaining('Gilbert'), + expect.stringContaining('Alexa'), + expect.stringContaining('May'), + expect.stringContaining('Deloise'), + ]); + }); + + it('iterates each line with line numbers starting from 1', async () => { + const onReadLine = mock(); + await forEachJsonLines(fixture('specification'), onReadLine); + + // Callback is invoked with (content, line, reader) => ... + expect(onReadLine).not.toHaveBeenCalledWith(expect.any(String), 0, expect.any(Object)); + expect(onReadLine).toHaveBeenCalledWith(expect.any(String), 1, expect.any(Object)); + expect(onReadLine).toHaveBeenCalledWith(expect.any(String), 2, expect.any(Object)); + expect(onReadLine).toHaveBeenCalledWith(expect.any(String), 3, expect.any(Object)); + expect(onReadLine).toHaveBeenCalledWith(expect.any(String), 4, expect.any(Object)); + }); +}); + +describe('parseJsonLine', () => { + it('parses a single line from file', async () => { + expect(await parseJsonLine(fixture('specification'), 1)).toMatchObject({ name: 'Gilbert' }); + expect(await parseJsonLine(fixture('specification'), 2)).toMatchObject({ name: 'Alexa' }); + expect(await parseJsonLine(fixture('specification'), 3)).toMatchObject({ name: 'May' }); + expect(await parseJsonLine(fixture('specification'), 4)).toMatchObject({ name: 'Deloise' }); + }); + + it('throws if single line is not found', async () => { + await expect(parseJsonLine(fixture('specification'), 99999)).rejects.toThrow( + 'Line 99999 not found in file' + ); + }); +}); + +describe('appendJsonLine', () => { + it('appends a single line to file', async () => { + const file = fixture('append-single', { temporary: true }); + await appendJsonLine(file, { name: 'Gilbert' }); + await expect(fs.promises.readFile(file, 'utf-8')).resolves.toBe('{"name":"Gilbert"}\n'); + }); + + it('appends multiple lines to file', async () => { + const file = fixture('append-multiple', { temporary: true }); + const data = [ + { name: 'Gilbert', list: ['some-list'] }, + { name: 'Alexa', nested: { nested: true, list: ['other', 'items'] } }, + { name: 'May', names: 1 }, + { name: 'Deloise', simple: true }, + ]; + + for (const item of data) { + await appendJsonLine(file, item); + } + + await expect(fs.promises.readFile(file, 'utf-8')).resolves.toBe( + data.map((item) => JSON.stringify(item) + '\n').join('') + ); + }); +}); + +/** + * Get the file path to a fixture, by name. + * This automatically adds the required `.jsonl` or `.temp.jsonl` extension. + * Use `temporary: true` to keep it out of the repository, and reset the content automatically. + */ +function fixture(name: string, { temporary = false }: { temporary?: boolean } = {}) { + const file = temporary + ? path.join(__dirname, 'fixtures', `${name}.temp.jsonl`) + : path.join(__dirname, 'fixtures', `${name}.jsonl`); + + if (temporary) { + fs.writeFileSync(file, ''); + } + + return file; +} diff --git a/src/utils/__tests__/ndjson.test.ts b/src/utils/__tests__/ndjson.test.ts deleted file mode 100644 index a4251db..0000000 --- a/src/utils/__tests__/ndjson.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it, mock } from 'bun:test'; -import path from 'path'; - -import { forEachJsonLines, parseJsonLine } from '../ndjson'; - -function fixture(...filePath: string[]) { - return path.join(__dirname, 'fixtures', ...filePath); -} - -describe('forEachJsonLines', () => { - it('iterates each line of file', async () => { - const lines: string[] = []; - await forEachJsonLines(fixture('ndjson.json'), (content) => { - lines.push(content); - }); - - expect(lines).toEqual([ - expect.stringContaining('Gilbert'), - expect.stringContaining('Alexa'), - expect.stringContaining('May'), - expect.stringContaining('Deloise'), - ]); - }); - - it('iterates each line with line numbers starting from 1', async () => { - const onReadLine = mock(); - await forEachJsonLines(fixture('ndjson.json'), onReadLine); - - // Callback is invoked with (content, line, reader) => ... - expect(onReadLine).not.toHaveBeenCalledWith(expect.any(String), 0, expect.any(Object)); - expect(onReadLine).toHaveBeenCalledWith(expect.any(String), 1, expect.any(Object)); - expect(onReadLine).toHaveBeenCalledWith(expect.any(String), 2, expect.any(Object)); - expect(onReadLine).toHaveBeenCalledWith(expect.any(String), 3, expect.any(Object)); - expect(onReadLine).toHaveBeenCalledWith(expect.any(String), 4, expect.any(Object)); - }); -}); - -describe('parseJsonLine', () => { - it('parses a single line from file', async () => { - expect(await parseJsonLine(fixture('ndjson.json'), 1)).toMatchObject({ name: 'Gilbert' }); - expect(await parseJsonLine(fixture('ndjson.json'), 2)).toMatchObject({ name: 'Alexa' }); - expect(await parseJsonLine(fixture('ndjson.json'), 3)).toMatchObject({ name: 'May' }); - expect(await parseJsonLine(fixture('ndjson.json'), 4)).toMatchObject({ name: 'Deloise' }); - }); - - it('throws if single line is not found', async () => { - await expect(parseJsonLine(fixture('ndjson.json'), 99999)).rejects.toThrow( - 'Line 99999 not found in file' - ); - }); -}); diff --git a/src/utils/ndjson.ts b/src/utils/jsonl.ts similarity index 100% rename from src/utils/ndjson.ts rename to src/utils/jsonl.ts diff --git a/src/utils/stats.ts b/src/utils/stats.ts index 8a32f20..f5dddd3 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -4,7 +4,7 @@ import path from 'path'; import { name, version } from '../../package.json'; import { env } from '../utils/env'; import { AtlasValidationError } from '../utils/errors'; -import { parseJsonLine } from '../utils/ndjson'; +import { parseJsonLine } from './jsonl'; export type StatsMetadata = { name: string; version: string };