Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions backend/src/dscore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Tests for the D-score function. No database is needed.

import { describe, expect, it } from 'vitest'
import { calculateDScore, type ScoredItem } from './dscore.js'
import { InvalidItemsError } from './errors.js'

const element = (
code: string,
value: string,
apparatus = 'UB',
): ScoredItem => ({
id: `${apparatus}-${code}-a`,
code,
value,
isVault: false,
})
const vault = (code: string, value: string): ScoredItem => ({
id: `VT-${code}-a`,
code,
value,
isVault: true,
})

describe('calculateDScore', () => {
it('gives 0 for an empty list', () => {
expect(calculateDScore([])).toBe(0)
})

it('sums all the elements when there are fewer than eight', () => {
expect(
calculateDScore([element('1.101', '0.1'), element('2.102', '0.5')]),
).toBe(0.6)
})

it('sums exactly eight elements', () => {
const items = Array.from({ length: 8 }, (_, i) =>
element(`3.${300 + i}`, '0.3'),
)
expect(calculateDScore(items)).toBe(2.4)
})

it('counts only the eight highest of more than eight', () => {
const values = [
'1.0',
'0.9',
'0.8',
'0.7',
'0.6',
'0.5',
'0.4',
'0.3',
'0.2',
'0.1',
]
const items = values.map((v, i) => element(`4.${400 + i}`, v))
// 1.0 + 0.9 + 0.8 + 0.7 + 0.6 + 0.5 + 0.4 + 0.3 = 5.2; 0.2 and 0.1 are left out
expect(calculateDScore(items)).toBe(5.2)
})

it('counts each code once, also when two variants are in the list', () => {
const a = element('1.101', '0.4')
const b = { ...element('1.101', '0.4'), id: 'UB-1.101-b' }
expect(calculateDScore([a, b])).toBe(0.4)
// The same code does not use up two of the eight places.
const others = Array.from({ length: 7 }, (_, i) =>
element(`5.${500 + i}`, '0.1'),
)
expect(calculateDScore([a, b, ...others])).toBe(1.1)
})

it('does not get rounding errors from decimals', () => {
// As floats 0.1 + 0.2 is 0.30000000000000004.
expect(
calculateDScore([element('1.101', '0.1'), element('1.102', '0.2')]),
).toBe(0.3)
const items = Array.from({ length: 8 }, (_, i) =>
element(`6.${600 + i}`, '0.7'),
)
expect(calculateDScore(items)).toBe(5.6)
})

it('accepts values that are not strings, such as Prisma decimals', () => {
const decimal = { toString: () => '0.8' }
expect(
calculateDScore([{ ...element('1.101', '0'), value: decimal }]),
).toBe(0.8)
})

it('gives the D-value of the vault on vault', () => {
expect(calculateDScore([vault('4.1', '2.6')])).toBe(2.6)
// Two variants of the same vault are one vault.
expect(
calculateDScore([
vault('4.1', '2.6'),
{ ...vault('4.1', '2.6'), id: 'VT-4.1-b' },
]),
).toBe(2.6)
})

it('refuses more than one vault', () => {
expect(() =>
calculateDScore([vault('4.1', '2.6'), vault('5.1', '3.0')]),
).toThrow(InvalidItemsError)
})

it('refuses items from more than one apparatus, also a vault with elements', () => {
expect(() =>
calculateDScore([element('1.101', '0.1'), element('1.102', '0.1', 'BB')]),
).toThrow(InvalidItemsError)
expect(() =>
calculateDScore([vault('4.1', '2.6'), element('1.101', '0.1')]),
).toThrow(InvalidItemsError)
})
})
60 changes: 60 additions & 0 deletions backend/src/dscore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// The D-score of a list of elements or vaults (constraint 13 in
// docs/DATA_MODEL.md). The calculator (`calculateDScore`) and, later,
// `Routine.dScore` use this function, so both follow the same rule.
//
// - Elements: the sum of the eight highest values, where each code counts once.
// Fewer than eight elements are all summed. An empty list gives 0.
// - Vault: the routine holds one vault and the score is that vault's D-value.
//
// The values have one decimal, so the sum is made in tenths (whole numbers) and
// divided at the end. Adding 0.1 + 0.2 as decimals gives 0.30000000000000004.

import { InvalidItemsError } from './errors.js'
import { apparatusOfId } from './server/items.js'

/** How many elements count in the score. */
export const COUNTED_ELEMENTS = 8

export type ScoredItem = {
id: string
code: string
/** `value` of an element, `dValue` of a vault. A string or Prisma Decimal with one decimal. */
value: { toString(): string }
isVault: boolean
}

const toTenths = (value: { toString(): string }): number =>
Math.round(Number(value.toString()) * 10)

/**
* The D-score of the items. Throws InvalidItemsError when they do not fit
* together: items from more than one apparatus (this includes a vault with
* elements), or more than one vault.
*/
export function calculateDScore(items: ScoredItem[]): number {
if (new Set(items.map((item) => apparatusOfId(item.id))).size > 1) {
throw new InvalidItemsError(
'The items must all be on the same apparatus, and a vault cannot be combined with elements.',
)
}

// One value for each code. Variants of a code have the same value, so the
// highest is the same as any.
const byCode = new Map<string, number>()
for (const item of items) {
const value = toTenths(item.value)
byCode.set(item.code, Math.max(byCode.get(item.code) ?? 0, value))
}

if (items.some((item) => item.isVault)) {
if (byCode.size > 1) {
throw new InvalidItemsError('A routine on vault has only one vault.')
}
return [...byCode.values()][0]! / 10
}

const highest = [...byCode.values()]
.sort((a, b) => b - a)
.slice(0, COUNTED_ELEMENTS)
return highest.reduce((sum, tenths) => sum + tenths, 0) / 10
}
22 changes: 20 additions & 2 deletions backend/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,32 @@ export class NotImplementedError extends Error {
}
}

/**
* The items do not fit together: items from more than one apparatus, a vault
* with elements, more than one vault, or an item that has been removed from
* the Code of Points. Code "INVALID_ITEMS".
*/
export class InvalidItemsError extends Error {
readonly code = 'INVALID_ITEMS'

constructor(message: string) {
super(message)
this.name = 'InvalidItemsError'
}
}

/** The errors above, which the server shows to the client with their code. */
export type ClientError =
BadUserInputError | UnauthenticatedError | NotImplementedError
| BadUserInputError
| UnauthenticatedError
| NotImplementedError
| InvalidItemsError

export function isClientError(error: unknown): error is ClientError {
return (
error instanceof BadUserInputError ||
error instanceof UnauthenticatedError ||
error instanceof NotImplementedError
error instanceof NotImplementedError ||
error instanceof InvalidItemsError
)
}
175 changes: 175 additions & 0 deletions backend/src/server/calculator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Tests for the calculator query. They run against the local database from
// backend/.env and need the seeded catalog (run `npm run db:seed` first). They
// only read. Without DATABASE_URL they are skipped.

import type { ApolloServer } from '@apollo/server'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { calculateDScore } from '../dscore.js'
import { createPrismaClient } from '../db.js'
import type { PrismaClient } from '../generated/prisma/client.js'
import { createApolloServer } from './app.js'
import type { Config } from './config.js'
import { type Context, createContext } from './context.js'
import { MAX_CALCULATOR_IDS } from './resolvers/calculator.js'

const hasDatabase = Boolean(process.env['DATABASE_URL'])
const config: Config = {
port: 3001,
imageBaseUrl: 'http://images.test/images',
corsOrigins: [],
}

type GqlError = { message: string; extensions?: Record<string, unknown> }
type Result = {
data: { calculateDScore: number } | null | undefined
errors: readonly GqlError[] | undefined
}

const QUERY = 'query ($ids: [ID!]!) { calculateDScore(itemIds: $ids) }'

describe.runIf(hasDatabase)('calculateDScore', () => {
let prisma: PrismaClient
let server: ApolloServer<Context>

const run = async (
ids: string[],
client: PrismaClient = prisma,
): Promise<Result> => {
const response = await server.executeOperation(
{ query: QUERY, variables: { ids } },
{ contextValue: createContext(client, config) },
)
if (response.body.kind !== 'single') throw new Error('expected one result')
const { data, errors } = response.body.singleResult
return { data: data as Result['data'], errors }
}
const codeOf = (r: Result) => r.errors?.[0]?.extensions?.['code']

beforeAll(async () => {
prisma = createPrismaClient()
server = createApolloServer()
await server.start()
})

afterAll(async () => {
await server.stop()
await prisma.$disconnect()
})

/** The values of the first n elements on an apparatus, from the database. */
async function elements(apparatusId: string, n: number) {
const rows = await prisma.element.findMany({
where: { id: { startsWith: `${apparatusId}-` }, removedAt: null },
orderBy: { id: 'asc' },
})
const seen = new Set<string>()
return rows.filter((r) => !seen.has(r.code) && seen.add(r.code)).slice(0, n)
}

it('is open to everyone: no login is needed', async () => {
const result = await run([])
expect(result.errors).toBeUndefined()
expect(result.data!.calculateDScore).toBe(0)
})

it('gives the same score as the function for elements of the catalog', async () => {
const rows = await elements('UB', 10)
const expected = calculateDScore(
rows.map((r) => ({
id: r.id,
code: r.code,
value: r.value,
isVault: false,
})),
)
const result = await run(rows.map((r) => r.id))
expect(result.errors).toBeUndefined()
expect(result.data!.calculateDScore).toBe(expected)
expect(expected).toBeGreaterThan(0)
})

it('gives the D-value of a vault', async () => {
const vault = await prisma.vault.findFirstOrThrow({
orderBy: { id: 'asc' },
})
const result = await run([vault.id])
expect(result.data!.calculateDScore).toBe(Number(vault.dValue.toString()))
})

it('counts the same id twice as once, and a variant of the same code once', async () => {
const [first] = await elements('BB', 1)
const once = await run([first!.id])
const twice = await run([first!.id, first!.id])
expect(twice.data!.calculateDScore).toBe(once.data!.calculateDScore)
})

it('refuses items that do not fit together with INVALID_ITEMS', async () => {
const [ub] = await elements('UB', 1)
const [bb] = await elements('BB', 1)
const vault = await prisma.vault.findFirstOrThrow({
orderBy: { id: 'asc' },
})
const second = await prisma.vault.findFirstOrThrow({
where: { code: { not: vault.code } },
orderBy: { id: 'asc' },
})
expect(codeOf(await run([ub!.id, bb!.id]))).toBe('INVALID_ITEMS')
expect(codeOf(await run([ub!.id, vault.id]))).toBe('INVALID_ITEMS')
expect(codeOf(await run([vault.id, second.id]))).toBe('INVALID_ITEMS')
})

it('refuses a removed item with INVALID_ITEMS', async () => {
const [row] = await elements('UB', 1)
const fake = {
element: {
findMany: () => Promise.resolve([{ ...row!, removedAt: new Date() }]),
},
vault: { findMany: () => Promise.resolve([]) },
} as unknown as PrismaClient
const result = await run([row!.id], fake)
expect(codeOf(result)).toBe('INVALID_ITEMS')
expect(result.errors![0]!.message).toContain(
'no longer in the Code of Points',
)
})

it('refuses too many ids, an unknown id and a badly formed id with BAD_USER_INPUT', async () => {
const many = Array.from(
{ length: MAX_CALCULATOR_IDS + 1 },
(_, i) => `UB-1.${i}-a`,
)
expect(codeOf(await run(many))).toBe('BAD_USER_INPUT')
expect(codeOf(await run(['UB-99.999-a']))).toBe('BAD_USER_INPUT')
for (const bad of [
'',
"UB-1.101-a'; DROP TABLE elements; --",
'x'.repeat(100),
'%',
]) {
expect(codeOf(await run([bad])), bad).toBe('BAD_USER_INPUT')
}
// Exactly 50 ids is fine.
const fifty = (await elements('FX', MAX_CALCULATOR_IDS)).map((r) => r.id)
expect(fifty.length).toBeGreaterThan(8)
expect((await run(fifty)).errors).toBeUndefined()
})

it('uses a fixed number of queries, not one per id', async () => {
const queries: string[] = []
const { PrismaClient: Logging } =
await import('../generated/prisma/client.js')
const { PrismaPg } = await import('@prisma/adapter-pg')
const logging = new Logging({
adapter: new PrismaPg({ connectionString: process.env['DATABASE_URL']! }),
log: [{ emit: 'event', level: 'query' }],
})
logging.$on('query', (e) => queries.push(e.query))
try {
const ids = (await elements('UB', 30)).map((r) => r.id)
await run(ids, logging as unknown as PrismaClient)
expect(queries.length).toBeLessThanOrEqual(2)
} finally {
await logging.$disconnect()
}
})
})
Loading