project-nomad/admin/tests/unit/kb_file_grouping.spec.ts

194 lines
8.0 KiB
TypeScript

import * as assert from 'node:assert/strict'
import { test } from 'node:test'
import {
classifyKbFile,
groupAndSortKbFiles,
sourceToDisplayName,
} from '../../inertia/lib/kb_file_grouping.js'
import type { StoredFileInfo } from '../../types/rag.js'
/** Wrap source paths into the minimal StoredFileInfo shape that
* `groupAndSortKbFiles` now expects. State + chunk count are irrelevant to
* grouping/sorting behavior; the per-file state-pill rendering is exercised
* separately in the modal's component tests (added in the follow-up PR).
* Metadata fields default to nullish/false — individual tests that exercise
* sort-by-size or sort-by-uploadedAt override as needed. */
const asInfos = (sources: string[]): StoredFileInfo[] =>
sources.map((source) => ({
source,
state: null,
chunksEmbedded: 0,
fileName: sourceToDisplayName(source),
size: null,
uploadedAt: null,
isUserUpload: classifyKbFile(source) === 'upload',
}))
test('classifyKbFile distinguishes ZIM, upload, admin_docs, and other', () => {
assert.equal(
classifyKbFile('/app/storage/zim/devdocs_en_python_2026-02.zim'),
'zim'
)
assert.equal(
classifyKbFile('/app/storage/kb_uploads/federalist.txt-8cc4ec95aa8f.txt'),
'upload'
)
assert.equal(classifyKbFile('/app/docs/release-notes.md'), 'admin_docs')
assert.equal(classifyKbFile('/app/README.md'), 'admin_docs')
assert.equal(classifyKbFile('/unexpected/path/file.txt'), 'other')
})
test('classifyKbFile does not match /app/READMEs that are not the bundled one', () => {
assert.equal(classifyKbFile('/app/README.md.bak'), 'other')
})
test('sourceToDisplayName returns the basename', () => {
assert.equal(
sourceToDisplayName('/app/storage/zim/devdocs_en_python_2026-02.zim'),
'devdocs_en_python_2026-02.zim'
)
assert.equal(sourceToDisplayName('/app/docs/release-notes.md'), 'release-notes.md')
})
test('groupAndSortKbFiles collapses all admin docs into a single row', () => {
const groups = groupAndSortKbFiles(asInfos([
'/app/docs/release-notes.md',
'/app/docs/getting-started.md',
'/app/docs/maps.md',
'/app/README.md',
]))
assert.equal(groups.length, 1)
assert.equal(groups[0].bucket, 'admin_docs')
assert.equal(groups[0].count, 4)
assert.equal(groups[0].displayName, 'Project NOMAD documentation · 4 files')
assert.deepEqual(groups[0].members.sort(), [
'/app/README.md',
'/app/docs/getting-started.md',
'/app/docs/maps.md',
'/app/docs/release-notes.md',
])
})
test('groupAndSortKbFiles orders buckets ZIM → upload → admin_docs → other', () => {
const groups = groupAndSortKbFiles(asInfos([
'/app/docs/release-notes.md',
'/unexpected/foo.txt',
'/app/storage/kb_uploads/upload.pdf',
'/app/storage/zim/devdocs.zim',
]))
assert.deepEqual(
groups.map((g) => g.bucket),
['zim', 'upload', 'admin_docs', 'other']
)
})
test('groupAndSortKbFiles alphabetizes within a bucket', () => {
const groups = groupAndSortKbFiles(asInfos([
'/app/storage/zim/wikipedia.zim',
'/app/storage/zim/devdocs.zim',
'/app/storage/zim/ifixit.zim',
]))
assert.deepEqual(
groups.map((g) => g.displayName),
['devdocs.zim', 'ifixit.zim', 'wikipedia.zim']
)
})
test('groupAndSortKbFiles uses singular noun when only one admin doc exists', () => {
const groups = groupAndSortKbFiles(asInfos(['/app/docs/release-notes.md']))
assert.equal(groups[0].displayName, 'Project NOMAD documentation · 1 file')
})
test('groupAndSortKbFiles handles empty input', () => {
assert.deepEqual(groupAndSortKbFiles([]), [])
})
test('groupAndSortKbFiles preserves a stable synthetic key for the admin docs group', () => {
const groups = groupAndSortKbFiles(asInfos([
'/app/docs/release-notes.md',
'/app/docs/maps.md',
]))
// The admin-docs row uses a synthetic source key (not a real path) so it
// can be used as a React key without colliding with any real file row.
assert.equal(groups[0].source, '__admin_docs_group__')
})
/** Sized fixtures for the sort tests. `size` and `uploadedAt` are set so the
* three sort keys (name, size, uploadedAt) produce visibly different orders —
* if a test passed for name it had better fail for size. */
const sized: StoredFileInfo[] = [
{ source: '/app/storage/kb_uploads/charlie.txt', state: null, chunksEmbedded: 0, fileName: 'charlie.txt', size: 100, uploadedAt: '2026-01-01T00:00:00Z', isUserUpload: true },
{ source: '/app/storage/kb_uploads/alpha.txt', state: null, chunksEmbedded: 0, fileName: 'alpha.txt', size: 300, uploadedAt: '2026-03-01T00:00:00Z', isUserUpload: true },
{ source: '/app/storage/kb_uploads/bravo.txt', state: null, chunksEmbedded: 0, fileName: 'bravo.txt', size: 200, uploadedAt: '2026-02-01T00:00:00Z', isUserUpload: true },
]
test('groupAndSortKbFiles sorts by size ascending', () => {
const groups = groupAndSortKbFiles(sized, { key: 'size', direction: 'asc' })
assert.deepEqual(groups.map((g) => g.displayName), ['charlie.txt', 'bravo.txt', 'alpha.txt'])
})
test('groupAndSortKbFiles sorts by size descending', () => {
const groups = groupAndSortKbFiles(sized, { key: 'size', direction: 'desc' })
assert.deepEqual(groups.map((g) => g.displayName), ['alpha.txt', 'bravo.txt', 'charlie.txt'])
})
test('groupAndSortKbFiles sorts by uploadedAt ascending', () => {
const groups = groupAndSortKbFiles(sized, { key: 'uploadedAt', direction: 'asc' })
assert.deepEqual(groups.map((g) => g.displayName), ['charlie.txt', 'bravo.txt', 'alpha.txt'])
})
test('groupAndSortKbFiles sorts by uploadedAt descending', () => {
const groups = groupAndSortKbFiles(sized, { key: 'uploadedAt', direction: 'desc' })
assert.deepEqual(groups.map((g) => g.displayName), ['alpha.txt', 'bravo.txt', 'charlie.txt'])
})
test('groupAndSortKbFiles parks files with null size at the end of size sort', () => {
const withMissing: StoredFileInfo[] = [
...sized,
{ source: '/app/storage/kb_uploads/zzz_missing.txt', state: null, chunksEmbedded: 0, fileName: 'zzz_missing.txt', size: null, uploadedAt: null, isUserUpload: true },
]
// Missing-size files sort last regardless of direction so the "real" data
// owns the top of the view either way.
const asc = groupAndSortKbFiles(withMissing, { key: 'size', direction: 'asc' })
assert.equal(asc.at(-1)?.displayName, 'zzz_missing.txt')
const desc = groupAndSortKbFiles(withMissing, { key: 'size', direction: 'desc' })
assert.equal(desc.at(-1)?.displayName, 'zzz_missing.txt')
})
test('groupAndSortKbFiles preserves bucket order across all sort modes', () => {
const mixed: StoredFileInfo[] = [
{ source: '/app/storage/zim/big.zim', state: null, chunksEmbedded: 0, fileName: 'big.zim', size: 999, uploadedAt: '2026-01-01T00:00:00Z', isUserUpload: false },
{ source: '/app/storage/kb_uploads/small.txt', state: null, chunksEmbedded: 0, fileName: 'small.txt', size: 1, uploadedAt: '2026-09-01T00:00:00Z', isUserUpload: true },
{ source: '/app/docs/release-notes.md', state: null, chunksEmbedded: 0, fileName: 'release-notes.md', size: 50, uploadedAt: '2026-05-01T00:00:00Z', isUserUpload: false },
]
// Even if size-desc would put zim first naturally, sort runs *within* a
// bucket — buckets themselves stay in the canonical zim → upload → admin_docs
// order. This is the invariant that lets the per-bucket grouping stay
// legible while still giving the user a sortable view.
for (const direction of ['asc', 'desc'] as const) {
for (const key of ['name', 'size', 'uploadedAt'] as const) {
const groups = groupAndSortKbFiles(mixed, { key, direction })
assert.deepEqual(
groups.map((g) => g.bucket),
['zim', 'upload', 'admin_docs'],
`bucket order changed for ${key}/${direction}`
)
}
}
})
test('groupAndSortKbFiles emits null metadata + isUserUpload=false for the admin_docs group', () => {
const groups = groupAndSortKbFiles(asInfos([
'/app/docs/release-notes.md',
'/app/README.md',
]))
assert.equal(groups[0].bucket, 'admin_docs')
assert.equal(groups[0].size, null)
assert.equal(groups[0].uploadedAt, null)
assert.equal(groups[0].isUserUpload, false)
})