From 60643546f6802420e3f26e8bbdcb979d20ecbd6b Mon Sep 17 00:00:00 2001 From: Craig Williams Date: Fri, 16 Oct 2026 16:29:08 +0100 Subject: [PATCH] Added tests for web/src/lib --- web/src/lib/classifications.test.ts | 29 +++++++++ web/src/lib/filterOpportunities.test.ts | 87 +++++++++++++++++++++++++ web/src/lib/opportunities.test.ts | 18 +++++ web/src/lib/testOpportunity.ts | 37 +++++++++++ web/vite.config.ts | 3 +- 5 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 web/src/lib/classifications.test.ts create mode 100644 web/src/lib/filterOpportunities.test.ts create mode 100644 web/src/lib/opportunities.test.ts create mode 100644 web/src/lib/testOpportunity.ts diff --git a/web/src/lib/classifications.test.ts b/web/src/lib/classifications.test.ts new file mode 100644 index 0000000..5f07e2e --- /dev/null +++ b/web/src/lib/classifications.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from 'vitest'; + +import type { ClassificationCode } from '@/types/Opportunities'; +import { applyClassificationChanges } from './classifications'; + +const code = (id: number, value: string): ClassificationCode => ({ + id, + is_cpv: true, + code: value, + classification: `Code ${value}`, +}); + +test('removes ids, adds codes and orders by code then id', () => { + const before = [code(1, '03000000'), code(2, '72000000'), code(3, '45000000')]; + const after = applyClassificationChanges(before, { + added: [code(4, '09000000'), code(5, '03000000')], + removed: [2], + }); + expect(after.map((c) => c.id)).toEqual([1, 5, 4, 3]); + expect(before).toHaveLength(3); +}); + +test('an added code replaces one with the same id', () => { + const after = applyClassificationChanges([code(1, '03000000')], { + added: [code(1, '03000001')], + removed: [], + }); + expect(after).toEqual([code(1, '03000001')]); +}); diff --git a/web/src/lib/filterOpportunities.test.ts b/web/src/lib/filterOpportunities.test.ts new file mode 100644 index 0000000..b3abafd --- /dev/null +++ b/web/src/lib/filterOpportunities.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'vitest'; + +import type { Annotation } from '@/types/Annotations'; +import type { OpportunityFilters } from './filterOpportunities'; +import { + activeFilterCount, + emptyFilters, + filterOpportunities, + NOT_MARKED, + statusLabel, + statusOptions, +} from './filterOpportunities'; +import { testOpportunity } from './testOpportunity'; + +const opportunities = [ + testOpportunity('a', { + title: 'Cloud hosting', + status: 'active', + sme_suitable: true, + classifications: [{ id: 1, is_cpv: true, code: '72000000', classification: 'IT' }], + latest_release_date: '2026-10-01T12:00:00Z', + }), + testOpportunity('b', { + title: 'Road repairs', + status: 'complete', + latest_release_date: '2026-10-03T12:00:00Z', + }), + testOpportunity('c', { + title: 'Cloud migration', + status: 'planned', + latest_release_date: '2026-10-02T12:00:00Z', + }), +]; + +const annotations: Record = { + a: { uuid: 'a', marks: ['interested'], hidden: false }, + b: { uuid: 'b', marks: [], hidden: true }, + c: { uuid: 'c', marks: [], hidden: false }, +}; +const annotationFor = (uuid: string): Annotation => + annotations[uuid] ?? { uuid, marks: [], hidden: false }; + +const filtered = (filters: Partial): string[] => + filterOpportunities(opportunities, { ...emptyFilters(), ...filters }, annotationFor).map( + (opportunity) => opportunity.uuid, + ); + +describe('filterOpportunities', () => { + test('hides hidden opportunities unless asked, newest release first', () => { + expect(filtered({})).toEqual(['c', 'a']); + expect(filtered({ showHidden: true })).toEqual(['b', 'c', 'a']); + }); + + test.each<{ name: string; filters: Partial; want: string[] }>([ + { name: 'title, ignoring case and spaces', filters: { title: ' CLOUD ' }, want: ['c', 'a'] }, + { name: 'statuses', filters: { statuses: new Set(['active', 'planned']) }, want: ['c', 'a'] }, + { name: 'a mark', filters: { marks: new Set(['interested']) }, want: ['a'] }, + { name: 'no marks', filters: { marks: new Set([NOT_MARKED]) }, want: ['c'] }, + { name: 'classification', filters: { classificationIds: new Set([1]) }, want: ['a'] }, + { name: 'SME suitable', filters: { smeSuitable: true }, want: ['a'] }, + ])('filters by $name', ({ filters, want }) => { + expect(filtered(filters)).toEqual(want); + }); + + test('combines filters', () => { + expect(filtered({ title: 'cloud', smeSuitable: true })).toEqual(['a']); + expect(filtered({ title: 'road', showHidden: true, smeSuitable: true })).toEqual([]); + }); +}); + +test('activeFilterCount counts the filters in use', () => { + expect(activeFilterCount(emptyFilters())).toBe(0); + expect( + activeFilterCount({ ...emptyFilters(), title: ' ', smeSuitable: true, showHidden: true }), + ).toBe(2); +}); + +test('statusOptions lists the known statuses, then others sorted', () => { + const options = statusOptions([ + testOpportunity('x', { status: 'zzz' }), + testOpportunity('y', { status: 'aaa' }), + testOpportunity('z', { status: 'active' }), + ]); + expect(options.slice(-2)).toEqual(['aaa', 'zzz']); + expect(options[0]).toBe('planning'); + expect(statusLabel('active')).toBe('Active'); +}); diff --git a/web/src/lib/opportunities.test.ts b/web/src/lib/opportunities.test.ts new file mode 100644 index 0000000..7faa089 --- /dev/null +++ b/web/src/lib/opportunities.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from 'vitest'; + +import { mergeOpportunities } from './opportunities'; +import { testOpportunity } from './testOpportunity'; + +test('replaces opportunities in place and adds new ones at the end', () => { + const list = [testOpportunity('a'), testOpportunity('b')]; + const merged = mergeOpportunities(list, [ + testOpportunity('c'), + testOpportunity('a', { title: 'Changed' }), + ]); + expect(merged.map((o) => [o.uuid, o.title])).toEqual([ + ['a', 'Changed'], + ['b', 'Opportunity b'], + ['c', 'Opportunity c'], + ]); + expect(list[0]?.title).toBe('Opportunity a'); +}); diff --git a/web/src/lib/testOpportunity.ts b/web/src/lib/testOpportunity.ts new file mode 100644 index 0000000..a3f6ac6 --- /dev/null +++ b/web/src/lib/testOpportunity.ts @@ -0,0 +1,37 @@ +import type { Opportunity } from '@/types/Opportunities'; + +/** An opportunity for tests, with overrides for the fields a test is about. */ +export function testOpportunity(uuid: string, overrides: Partial = {}): Opportunity { + return { + uuid, + uuid_type: 'ocid', + title: `Opportunity ${uuid}`, + status: 'active', + description: '', + buyer_name: 'Example Council', + buyer_id: 'GB-EXAMPLE', + classifications: [], + main_category: 'services', + procurement_method: 'open', + multi_lot: false, + sme_suitable: false, + vcse_suitable: false, + start_date: null, + end_date: null, + gross_value: 0, + has_documents: false, + source: 'FindATender', + latest_release_date: '2026-10-01T12:00:00Z', + releases: null, + marks: { + uuid, + hidden: false, + skipped: false, + interested: false, + bid_submitted: false, + won: false, + lost: false, + }, + ...overrides, + }; +} diff --git a/web/vite.config.ts b/web/vite.config.ts index aa056ff..3ee4ce3 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -26,10 +26,9 @@ export default defineConfig(({ mode }) => { }, // Vitest. vite-plugin-solid would run tests in jsdom, which isn't // installed: nothing tested yet renders, and Solid's reactivity works - // in Node as it is. passWithNoTests until the first tests are added. + // in Node as it is. test: { environment: 'node', - passWithNoTests: true, }, }; });