feat(search): add groupDocuments utility with unit tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
165
frontend/src/lib/utils/groupDocuments.spec.ts
Normal file
165
frontend/src/lib/utils/groupDocuments.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { groupDocuments } from './groupDocuments';
|
||||
|
||||
type Doc = {
|
||||
id: string;
|
||||
documentDate?: string | null;
|
||||
sender?: { displayName: string } | null;
|
||||
receivers?: { displayName: string }[];
|
||||
};
|
||||
|
||||
const doc = (overrides: Partial<Doc> & { id: string }): Doc => ({
|
||||
documentDate: null,
|
||||
sender: null,
|
||||
receivers: [],
|
||||
...overrides
|
||||
});
|
||||
|
||||
// ─── DATE sort ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('groupDocuments — DATE sort', () => {
|
||||
it('produces one group per distinct year', () => {
|
||||
const docs = [
|
||||
doc({ id: 'a', documentDate: '1923-04-12' }),
|
||||
doc({ id: 'b', documentDate: '1938-01-01' }),
|
||||
doc({ id: 'c', documentDate: '1965-08-03' })
|
||||
];
|
||||
const groups = groupDocuments(docs, 'DATE', 'Undatiert');
|
||||
expect(groups.map((g) => g.label)).toEqual(['1923', '1938', '1965']);
|
||||
expect(groups.every((g) => g.documents.length === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('puts multiple docs from the same year into one group', () => {
|
||||
const docs = [
|
||||
doc({ id: 'a', documentDate: '1938-03-01' }),
|
||||
doc({ id: 'b', documentDate: '1938-11-15' })
|
||||
];
|
||||
const groups = groupDocuments(docs, 'DATE', 'Undatiert');
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].label).toBe('1938');
|
||||
expect(groups[0].documents).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('places undated docs in the fallback group at the bottom', () => {
|
||||
const docs = [
|
||||
doc({ id: 'a', documentDate: '1938-01-01' }),
|
||||
doc({ id: 'b', documentDate: null }),
|
||||
doc({ id: 'c', documentDate: null })
|
||||
];
|
||||
const groups = groupDocuments(docs, 'DATE', 'Undatiert');
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0].label).toBe('1938');
|
||||
expect(groups[1].label).toBe('Undatiert');
|
||||
expect(groups[1].documents.map((d) => d.id)).toEqual(['b', 'c']);
|
||||
});
|
||||
|
||||
it('returns one group with fallback label when all docs are undated', () => {
|
||||
const docs = [doc({ id: 'a' }), doc({ id: 'b' })];
|
||||
const groups = groupDocuments(docs, 'DATE', 'Undatiert');
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].label).toBe('Undatiert');
|
||||
});
|
||||
|
||||
it('returns one group when all docs are from the same year', () => {
|
||||
const docs = [
|
||||
doc({ id: 'a', documentDate: '1938-01-01' }),
|
||||
doc({ id: 'b', documentDate: '1938-06-15' })
|
||||
];
|
||||
const groups = groupDocuments(docs, 'DATE', 'Undatiert');
|
||||
expect(groups).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── SENDER sort ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('groupDocuments — SENDER sort', () => {
|
||||
it('produces one group per distinct sender', () => {
|
||||
const docs = [
|
||||
doc({ id: 'a', sender: { displayName: 'Anna Müller' } }),
|
||||
doc({ id: 'b', sender: { displayName: 'Karl Bauer' } }),
|
||||
doc({ id: 'c', sender: { displayName: 'Anna Müller' } })
|
||||
];
|
||||
const groups = groupDocuments(docs, 'SENDER', 'Unbekannt');
|
||||
expect(groups.map((g) => g.label)).toEqual(['Anna Müller', 'Karl Bauer']);
|
||||
expect(groups[0].documents).toHaveLength(2);
|
||||
expect(groups[1].documents).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('places docs with no sender in the fallback group at the bottom', () => {
|
||||
const docs = [
|
||||
doc({ id: 'a', sender: { displayName: 'Anna Müller' } }),
|
||||
doc({ id: 'b', sender: null })
|
||||
];
|
||||
const groups = groupDocuments(docs, 'SENDER', 'Unbekannt');
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0].label).toBe('Anna Müller');
|
||||
expect(groups[1].label).toBe('Unbekannt');
|
||||
expect(groups[1].documents[0].id).toBe('b');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── RECEIVER sort ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('groupDocuments — RECEIVER sort', () => {
|
||||
it('a doc with two receivers appears in both receiver groups', () => {
|
||||
const docs = [
|
||||
doc({
|
||||
id: 'a',
|
||||
receivers: [{ displayName: 'Anna' }, { displayName: 'Karl' }]
|
||||
})
|
||||
];
|
||||
const groups = groupDocuments(docs, 'RECEIVER', 'Unbekannt');
|
||||
expect(groups.map((g) => g.label)).toEqual(['Anna', 'Karl']);
|
||||
expect(groups[0].documents[0].id).toBe('a');
|
||||
expect(groups[1].documents[0].id).toBe('a');
|
||||
});
|
||||
|
||||
it('places docs with no receivers in the fallback group at the bottom', () => {
|
||||
const docs = [
|
||||
doc({ id: 'a', receivers: [{ displayName: 'Anna' }] }),
|
||||
doc({ id: 'b', receivers: [] })
|
||||
];
|
||||
const groups = groupDocuments(docs, 'RECEIVER', 'Unbekannt');
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0].label).toBe('Anna');
|
||||
expect(groups[1].label).toBe('Unbekannt');
|
||||
expect(groups[1].documents[0].id).toBe('b');
|
||||
});
|
||||
|
||||
it('composite keys are unique: groupLabel + doc.id identifies each item', () => {
|
||||
const docs = [
|
||||
doc({ id: 'a', receivers: [{ displayName: 'Anna' }, { displayName: 'Karl' }] }),
|
||||
doc({ id: 'b', receivers: [{ displayName: 'Anna' }] })
|
||||
];
|
||||
const groups = groupDocuments(docs, 'RECEIVER', 'Unbekannt');
|
||||
const keys = groups.flatMap((g) => g.documents.map((d) => `${g.label}-${d.id}`));
|
||||
const uniqueKeys = new Set(keys);
|
||||
expect(uniqueKeys.size).toBe(keys.length);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Non-groupable sorts ──────────────────────────────────────────────────────
|
||||
|
||||
describe('groupDocuments — non-groupable sorts', () => {
|
||||
it('TITLE sort returns one group containing all documents', () => {
|
||||
const docs = [doc({ id: 'a' }), doc({ id: 'b' })];
|
||||
const groups = groupDocuments(docs, 'TITLE', 'Undatiert');
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].documents).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('UPLOAD_DATE sort returns one group containing all documents', () => {
|
||||
const docs = [doc({ id: 'a' }), doc({ id: 'b' })];
|
||||
const groups = groupDocuments(docs, 'UPLOAD_DATE', 'Undatiert');
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].documents).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Edge cases ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('groupDocuments — edge cases', () => {
|
||||
it('returns empty array for an empty document list', () => {
|
||||
expect(groupDocuments([], 'DATE', 'Undatiert')).toEqual([]);
|
||||
});
|
||||
});
|
||||
56
frontend/src/lib/utils/groupDocuments.ts
Normal file
56
frontend/src/lib/utils/groupDocuments.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export type GroupableDoc = {
|
||||
id: string;
|
||||
documentDate?: string | null;
|
||||
sender?: { displayName: string } | null;
|
||||
receivers?: { displayName: string }[];
|
||||
};
|
||||
|
||||
export type DocumentGroup<T extends GroupableDoc> = {
|
||||
label: string;
|
||||
documents: T[];
|
||||
};
|
||||
|
||||
const GROUPABLE_SORTS = ['DATE', 'SENDER', 'RECEIVER'] as const;
|
||||
type GroupableSort = (typeof GROUPABLE_SORTS)[number];
|
||||
|
||||
export function groupDocuments<T extends GroupableDoc>(
|
||||
docs: T[],
|
||||
sort: string,
|
||||
fallbackLabel: string
|
||||
): DocumentGroup<T>[] {
|
||||
if (docs.length === 0) return [];
|
||||
if (!GROUPABLE_SORTS.includes(sort as GroupableSort)) {
|
||||
return [{ label: '', documents: [...docs] }];
|
||||
}
|
||||
|
||||
const groupMap = new Map<string, T[]>();
|
||||
const fallbackDocs: T[] = [];
|
||||
|
||||
for (const doc of docs) {
|
||||
const keys = extractGroupKeys(doc, sort as GroupableSort);
|
||||
if (keys.length === 0) {
|
||||
fallbackDocs.push(doc);
|
||||
} else {
|
||||
for (const key of keys) {
|
||||
if (!groupMap.has(key)) groupMap.set(key, []);
|
||||
groupMap.get(key)!.push(doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const groups = [...groupMap.entries()].map(([label, documents]) => ({ label, documents }));
|
||||
if (fallbackDocs.length > 0) groups.push({ label: fallbackLabel, documents: fallbackDocs });
|
||||
return groups;
|
||||
}
|
||||
|
||||
function extractGroupKeys<T extends GroupableDoc>(doc: T, sort: GroupableSort): string[] {
|
||||
if (sort === 'DATE') {
|
||||
const year = doc.documentDate
|
||||
? String(new Date(doc.documentDate + 'T12:00:00').getFullYear())
|
||||
: null;
|
||||
return year ? [year] : [];
|
||||
}
|
||||
if (sort === 'SENDER') return doc.sender ? [doc.sender.displayName] : [];
|
||||
if (sort === 'RECEIVER') return (doc.receivers ?? []).map((r) => r.displayName);
|
||||
return [];
|
||||
}
|
||||
Reference in New Issue
Block a user