diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8ff28f4..2ae0eed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,6 +28,8 @@ jobs: run: npm ci - name: Run tests run: npm test + - name: Check types + run: npm run typecheck - name: Run format check run: npm run format:check - name: Calculate coverage diff --git a/README.md b/README.md index 676a140..e901c6f 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,18 @@ This package contains TypeScript interfaces for the different classes used in WC npm install @wca/helpers --save ``` +## WCIF version compatibility + +`@wca/helpers` 2.x targets WCIF 2.x, including WCIF 2.2. Use +`@wca/helpers` 1.x for WCIF 1.x. + ## Usage example ## Development setup +Run `npm test` for the unit tests and `npm run typecheck` to check the +TypeScript source. + ## Release process Releases are managed by [Release Please](https://github.com/googleapis/release-please). diff --git a/package-lock.json b/package-lock.json index a00baa8..d7f7cc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2643,22 +2643,6 @@ "node": ">=0.4.0" } }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -3775,6 +3759,30 @@ "node": ">=6" } }, + "node_modules/har-validator/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/har-validator/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5605,12 +5613,6 @@ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "dev": true }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -6795,6 +6797,7 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } @@ -8913,18 +8916,6 @@ "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", "dev": true }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, "ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -9752,6 +9743,26 @@ "requires": { "ajv": "^6.12.3", "har-schema": "^2.0.0" + }, + "dependencies": { + "ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + } } }, "has-flag": { @@ -11124,12 +11135,6 @@ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "dev": true }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", diff --git a/package.json b/package.json index 50c298a..e035b9c 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ ], "scripts": { "test": "jest", + "typecheck": "tsc --noEmit", "test:coverage": "jest --coverage", "format": "prettier --write \"**/*.{ts,js}\"", "format:check": "prettier --check \"**/*.{ts,js}\"", diff --git a/spec/format.spec.ts b/spec/format.spec.ts new file mode 100644 index 0000000..e264019 --- /dev/null +++ b/spec/format.spec.ts @@ -0,0 +1,17 @@ +import { + getFormatExpectedSolves, + getFormatName, + getFormatRanking, + getFormatTrimBest, + getFormatTrimWorst, +} from '../src/helpers/format'; + +describe('Format helper', function () { + it('returns the Head-to-Head format properties', function () { + expect(getFormatName('h')).toBe('Head-to-Head'); + expect(getFormatRanking('h')).toEqual([]); + expect(getFormatExpectedSolves('h')).toBe(0); + expect(getFormatTrimBest('h')).toBe(0); + expect(getFormatTrimWorst('h')).toBe(0); + }); +}); diff --git a/spec/ranking.spec.ts b/spec/ranking.spec.ts index 8187b43..fff4939 100644 --- a/spec/ranking.spec.ts +++ b/spec/ranking.spec.ts @@ -44,7 +44,7 @@ function createResult(personId: number, plain: number[]): Result { }; plain.forEach((p) => r.attempts.push({ - result: p, + value: p, reconstruction: null, }), ); @@ -52,6 +52,20 @@ function createResult(personId: number, plain: number[]): Result { } describe('Ranking Helper', function () { + it('keeps the supplied order and rankings when no ranking order exists', function () { + let results: Result[] = [ + { ...createResult(2, [900]), ranking: 1 }, + { ...createResult(1, [800]), ranking: 2 }, + ]; + + let rankedResults = rank(results, []); + + expect(rankedResults[0].personId).toBe(2); + expect(rankedResults[0]).toHaveRanking(1); + expect(rankedResults[1].personId).toBe(1); + expect(rankedResults[1]).toHaveRanking(2); + }); + describe('Average only', function () { describe('Ao5', function () { it('ranks 2 people with the same average the same', function () { diff --git a/src/helpers/average.ts b/src/helpers/average.ts index 0b5809b..89225e2 100644 --- a/src/helpers/average.ts +++ b/src/helpers/average.ts @@ -1,7 +1,7 @@ -import { AttemptResult } from '../models/attemptResult'; +import { ResultValue } from '../models/resultValue'; import { isMultiResult } from './result'; -export function Ao5(results: AttemptResult[]): AttemptResult | null { +export function Ao5(results: ResultValue[]): ResultValue | null { results = results.filter((r) => r !== 0); if (results.some(isMultiResult)) return null; // cannot calculate average or mean for MBLD @@ -38,7 +38,7 @@ export function Ao5(results: AttemptResult[]): AttemptResult | null { return avg; } -export function Mo3(results: AttemptResult[]): AttemptResult | null { +export function Mo3(results: ResultValue[]): ResultValue | null { results = results.filter((r) => r !== 0); // remove non-existing attempts if (results.some(isMultiResult)) return null; // cannot calculate average or mean for MBLD diff --git a/src/helpers/format.ts b/src/helpers/format.ts index e00439c..056e612 100644 --- a/src/helpers/format.ts +++ b/src/helpers/format.ts @@ -14,6 +14,8 @@ export function getFormatName(format: RoundFormat): string { return 'Average of 5'; case 'm': return 'Mean of 3'; + case 'h': + return 'Head-to-Head'; } } @@ -24,6 +26,8 @@ export function getFormatRanking(format: RoundFormat): RankingType[] { case '3': case '5': return ['single']; + case 'h': + return []; case 'a': case 'm': return ['average', 'single']; @@ -44,6 +48,8 @@ export function getFormatExpectedSolves(format: RoundFormat): number { return 5; case 'm': return 3; + case 'h': + return 0; } } @@ -53,6 +59,7 @@ export function getFormatTrimBest(format: RoundFormat): number { case '2': case '3': case 'm': + case 'h': return 0; case 'a': case '5': @@ -66,6 +73,7 @@ export function getFormatTrimWorst(format: RoundFormat): number { case '2': case '3': case 'm': + case 'h': return 0; case 'a': case '5': diff --git a/src/helpers/ranking.ts b/src/helpers/ranking.ts index 6769245..76a1a72 100644 --- a/src/helpers/ranking.ts +++ b/src/helpers/ranking.ts @@ -1,15 +1,17 @@ import { Result } from '../models/result'; import { RankingType } from '../models/rankingType'; import { Ao5, Mo3 } from './average'; -import { AttemptResult } from '../models/attemptResult'; +import { ResultValue } from '../models/resultValue'; export function rank(results: Result[], rankingOrder: RankingType[]): Result[] { - let averageCache: { [personId: number]: AttemptResult | null } = {}; + if (rankingOrder.length === 0) return results; + + let averageCache: { [personId: number]: ResultValue | null } = {}; let bestCache: { [personId: number]: number } = {}; // first calc bests and averages where applicable results.forEach((r) => { - let plain = r.attempts.map((a) => a.result); + let plain = r.attempts.map((a) => a.value); if (rankingOrder.indexOf('average') > -1) { let average = r.attempts.length === 5 ? Ao5(plain) : Mo3(plain); if ((average as number) < 0) { diff --git a/src/helpers/result.ts b/src/helpers/result.ts index 66aff24..d9ce561 100644 --- a/src/helpers/result.ts +++ b/src/helpers/result.ts @@ -1,4 +1,4 @@ -import { AttemptResult } from '../models/attemptResult'; +import { ResultValue } from '../models/resultValue'; import { formatCentiseconds } from './time'; type DnfMultiResult = { isDnf: true }; @@ -11,21 +11,21 @@ type ValidMultiResult = { type DecodedMultiResult = DnfMultiResult | DnsMultiResult | ValidMultiResult; -export function isDnf(result: AttemptResult): boolean { +export function isDnf(result: ResultValue): boolean { return result === -1; } -export function isDns(result: AttemptResult): boolean { +export function isDns(result: ResultValue): boolean { return result === -2; } -export function isMultiResult(result: AttemptResult): boolean { +export function isMultiResult(result: ResultValue): boolean { // Formatted as a base 10 string, a multi result will always have 8 or 9 digits. // New style mbld has an implied 0 at the beginning which is not included in the length return [9, 10].indexOf(result.toString().length) > -1; } -export function decodeMultiResult(result: AttemptResult): DecodedMultiResult { +export function decodeMultiResult(result: ResultValue): DecodedMultiResult { let r = result; if (r === -1) { return { isDnf: true }; @@ -88,7 +88,7 @@ function isDnsMultiResult( return result.hasOwnProperty('isDns'); } -function decodeOldMultiResult(result: AttemptResult): DecodedMultiResult { +function decodeOldMultiResult(result: ResultValue): DecodedMultiResult { // Handles DNF and DNS if (result <= 0) return { solved: 0, attempted: 0, centiseconds: result }; @@ -104,7 +104,7 @@ function decodeOldMultiResult(result: AttemptResult): DecodedMultiResult { return res; } -function decodeNewMultiResult(result: AttemptResult): DecodedMultiResult { +function decodeNewMultiResult(result: ResultValue): DecodedMultiResult { // Handles DNF and DNS if (result <= 0) return { solved: 0, attempted: 0, centiseconds: result }; diff --git a/src/models/advancementCondition.ts b/src/models/advancementCondition.ts deleted file mode 100644 index 97ccbfc..0000000 --- a/src/models/advancementCondition.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { AttemptResult } from './attemptResult'; - -interface RankingAdvancement { - type: 'ranking'; - level: number; -} - -interface PercentAdvancement { - type: 'percent'; - level: number; -} - -interface ResultAdvancement { - type: 'attemptResult'; - level: AttemptResult; -} - -export type AdvancementCondition = - | RankingAdvancement - | PercentAdvancement - | ResultAdvancement; diff --git a/src/models/attempt.ts b/src/models/attempt.ts index 3961649..fabcefb 100644 --- a/src/models/attempt.ts +++ b/src/models/attempt.ts @@ -1,6 +1,6 @@ -import { AttemptResult } from './attemptResult'; +import { ResultValue } from './resultValue'; export interface Attempt { - result: AttemptResult; + value: ResultValue; reconstruction: string | null; } diff --git a/src/models/attemptResult.ts b/src/models/attemptResult.ts deleted file mode 100644 index 98331ff..0000000 --- a/src/models/attemptResult.ts +++ /dev/null @@ -1 +0,0 @@ -export type AttemptResult = number; diff --git a/src/models/competition.ts b/src/models/competition.ts index 01b7838..a5a98db 100644 --- a/src/models/competition.ts +++ b/src/models/competition.ts @@ -13,7 +13,7 @@ export interface Competition { persons: Person[]; events: Event[]; schedule: Schedule; - series: Series[]; + series: Series | null; competitorLimit: number | null; extensions: Extension[]; registrationInfo: RegistrationInfo; diff --git a/src/models/cutoff.ts b/src/models/cutoff.ts index 5f4786e..6ae5d83 100644 --- a/src/models/cutoff.ts +++ b/src/models/cutoff.ts @@ -1,6 +1,6 @@ -import { AttemptResult } from './attemptResult'; +import { ResultValue } from './resultValue'; export interface Cutoff { numberOfAttempts: number; - attemptResult: AttemptResult; + resultValue: ResultValue; } diff --git a/src/models/eventId.ts b/src/models/eventId.ts index c67cca5..adc6a02 100644 --- a/src/models/eventId.ts +++ b/src/models/eventId.ts @@ -15,7 +15,8 @@ export type CurrentEventId = | 'sq1' // other puzzles | '444bf' | '555bf' - | '333mbf'; // big and multiblind + | '333mbf' // big and multiblind + | 'fto'; export type DeprecatedEventId = 'magic' | 'mmagic' | '333mbo' | '333ft'; diff --git a/src/models/index.ts b/src/models/index.ts index 62e5d14..2a7b2dc 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,8 +1,9 @@ -export * from './attemptResult'; +export * from './resultValue'; export * from './attempt'; export * from './result'; export * from './qualification'; -export * from './advancementCondition'; +export * from './resultCondition'; +export * from './participationRuleset'; export * from './cutoff'; export * from './competition'; export * from './timeLimit'; diff --git a/src/models/participationRuleset.ts b/src/models/participationRuleset.ts new file mode 100644 index 0000000..e169d91 --- /dev/null +++ b/src/models/participationRuleset.ts @@ -0,0 +1,32 @@ +import { ResultCondition } from './resultCondition'; + +export interface RegistrationsParticipationSource { + type: 'registrations'; +} + +export interface RoundParticipationSource { + type: 'round'; + roundId: string; + resultCondition: ResultCondition | null; +} + +export interface LinkedRoundsParticipationSource { + type: 'linkedRounds'; + roundIds: string[]; + resultCondition: ResultCondition | null; +} + +export type ParticipationSource = + | RegistrationsParticipationSource + | RoundParticipationSource + | LinkedRoundsParticipationSource; + +export interface ReservedPlaces { + nationalities: string[]; + reservations: number; +} + +export interface ParticipationRuleset { + participationSource: ParticipationSource | null; + reservedPlaces: ReservedPlaces | null; +} diff --git a/src/models/person.ts b/src/models/person.ts index cb02103..394d8ce 100644 --- a/src/models/person.ts +++ b/src/models/person.ts @@ -8,7 +8,7 @@ import { Extension } from './extension'; export type RegistrantId = number; export interface Person { - registrantId: RegistrantId; + registrantId: RegistrantId | null; name: string; wcaUserId: number; wcaId?: string | null; diff --git a/src/models/personalBest.ts b/src/models/personalBest.ts index 375258f..0b621f8 100644 --- a/src/models/personalBest.ts +++ b/src/models/personalBest.ts @@ -1,10 +1,10 @@ import { EventId } from './eventId'; -import { AttemptResult } from './attemptResult'; import { RankingType } from './rankingType'; +import { ResultValue } from './resultValue'; export interface PersonalBest { eventId: EventId; - best: AttemptResult; + value: ResultValue; worldRanking: number; continentalRanking: number; nationalRanking: number; diff --git a/src/models/qualification.ts b/src/models/qualification.ts index 0eac292..89351fd 100644 --- a/src/models/qualification.ts +++ b/src/models/qualification.ts @@ -1,28 +1,7 @@ -import { AttemptResult } from './attemptResult'; -import { RankingType } from './rankingType'; +import { QualificationResultCondition } from './resultCondition'; -interface RankingQualification { - whenDate: string; - type: 'ranking'; - resultType: RankingType; - level: number; +export interface Qualification { + earliestResultDate: string | null; + latestResultDate: string; + resultCondition: QualificationResultCondition; } - -interface AttemptResultQualification { - when: string; - type: 'attemptResult'; - resultType: RankingType; - level: AttemptResult; -} - -interface AnyResultQualificiation { - when: string; - type: 'anyResult'; - resultType: RankingType; - level: AttemptResult; -} - -export type Qualification = - | RankingQualification - | AttemptResultQualification - | AnyResultQualificiation; diff --git a/src/models/result.ts b/src/models/result.ts index 8ebe6b6..f0cb78f 100644 --- a/src/models/result.ts +++ b/src/models/result.ts @@ -1,11 +1,11 @@ import { Attempt } from './attempt'; -import { AttemptResult } from './attemptResult'; import { RegistrantId } from './person'; +import { ResultValue } from './resultValue'; export interface Result { personId: RegistrantId; ranking: number | null; attempts: Attempt[]; - best: AttemptResult; - average: AttemptResult; + best: ResultValue; + average: ResultValue; } diff --git a/src/models/resultCondition.ts b/src/models/resultCondition.ts new file mode 100644 index 0000000..a3733ea --- /dev/null +++ b/src/models/resultCondition.ts @@ -0,0 +1,27 @@ +import { RankingType } from './rankingType'; +import { ResultValue } from './resultValue'; + +export interface ResultAchievedCondition { + type: 'resultAchieved'; + scope: RankingType; + value: ResultValue | null; +} + +export interface RankingCondition { + type: 'ranking'; + scope: RankingType; + value: number; +} + +export interface PercentCondition { + type: 'percent'; + scope: RankingType; + value: number; +} + +export type ResultCondition = + | ResultAchievedCondition + | RankingCondition + | PercentCondition; + +export type QualificationResultCondition = ResultCondition | null; diff --git a/src/models/resultValue.ts b/src/models/resultValue.ts new file mode 100644 index 0000000..6d59115 --- /dev/null +++ b/src/models/resultValue.ts @@ -0,0 +1 @@ +export type ResultValue = number; diff --git a/src/models/round.ts b/src/models/round.ts index e1eed3d..4a1cc3c 100644 --- a/src/models/round.ts +++ b/src/models/round.ts @@ -1,7 +1,7 @@ import { RoundFormat } from './roundFormat'; import { TimeLimit } from './timeLimit'; import { Cutoff } from './cutoff'; -import { AdvancementCondition } from './advancementCondition'; +import { ParticipationRuleset } from './participationRuleset'; import { Result } from './result'; import { ScrambleSet } from './scrambleSet'; import { Extension } from './extension'; @@ -9,10 +9,11 @@ import { ActivityCode } from './activity'; export interface Round { id: ActivityCode; + linkedRounds: string[] | null; format: RoundFormat; timeLimit: TimeLimit | null; cutoff: Cutoff | null; - advancementCondition: AdvancementCondition | null; + participationRuleset: ParticipationRuleset | null; results: Result[]; scrambleSetCount?: number; scrambleSets?: ScrambleSet[]; diff --git a/src/models/roundFormat.ts b/src/models/roundFormat.ts index b9cc89e..1de51ab 100644 --- a/src/models/roundFormat.ts +++ b/src/models/roundFormat.ts @@ -1 +1 @@ -export type RoundFormat = '1' | '2' | '3' | '5' | 'a' | 'm'; +export type RoundFormat = '1' | '2' | '3' | '5' | 'a' | 'm' | 'h';