diff --git a/documentation/tick_logic.md b/documentation/tick_logic.md index 4e630d0..91e9b9b 100644 --- a/documentation/tick_logic.md +++ b/documentation/tick_logic.md @@ -16,29 +16,29 @@ Let's get to the technical details. There are 3 layers to this architecturally i * `Tick.style` * `Tick.attemptType` -(Here)[https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/graphql/schema/Climb.gql#L115] are all the possible values for `Climb.type`, as defined in the [limb Schema](https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/graphql/schema/Climb.gql#L115), and style and attempts types defined in the [TickSchema](https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/db/TickTypes.ts). +Here are all the possible values for `Climb.type` (also called discipline), as defined in the [Climb Schema](https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/graphql/schema/Climb.gql#L115), and Tick style and attemptsTypes defined in the [TickSchema](https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/db/TickTypes.ts). | Climb.type | Tick.style | Tick.attemptType | |---------------|------------|------------------| -| trad | Lead | Onsight | -| sport | Follow | Flash | -| bouldering | TR | Redpoint | -| deepwatersolo | Solo | Pinkpoint | -| snow | Aid | Send | -| ice | Boulder | Attempt | -| aid | Frenchfree | | -| tr | | -| alpine | | -| mixed | | +| trad | Lead | Onsight | +| sport | Follow | Flash | +| bouldering | TR | Redpoint | +| deepwatersolo | Solo | Pinkpoint | +| snow | Aid | Send | +| ice | Boulder | Attempt | +| aid | | Frenchfree | +| tr | | | +| alpine | | | +| mixed | | | See the [Wikipedia Glossary of Climbing Terms](https://en.wikipedia.org/wiki/Glossary_of_climbing_terms) for common definitions of all these terms. -Given the 10 climb types, 6 styles, and 7 attempt types, there are 10*6*7=**420** diffent ways to "tick" a route. +Given the 10 climb types, 6 styles, and 7 attempt types, there are `10*6*7=`**420** diffent ways to "tick" a route. *(Thats not even accounting for the fact that a route can be multiple disciplines, eg: boulder & TR, or sport & deepwatersolo. If you really want to get nerdy: with the `2^10=1024` possible discipline combinations, there are a whopping `1024*6*7=`**43,008** ways to tick a route!)* -Here's a Hierarchical way to restrict values: +## Here's a Hierarchical way to restrict values: -## Climb type -> Tick Style +### Climb type -> Tick Style | Climb Type | logical description | Tick Style Options | |-------------------|---------------------|-------------------- | @@ -48,15 +48,17 @@ Here's a Hierarchical way to restrict values: | 'deepwatersolo' or leadable or aidable or topropeable | soloable | Solo | | bouldering | boulderable | Boulder | -## Tick Style -> Tick Attempt Type + +Since a route can have multiple disciplines, these options are composable. eg: a route marked as 'trad, aid', is both 'leadable' and 'aidable'. A route that is 'boulder, tr', is both 'boulderable' and 'topropeable' + +### Tick Style -> Tick Attempt Type | Tick Style | Attempt Type options | |------------|----------------------| | 'Lead' | 'Onsight', 'Flash', 'Redpoint', 'Pinkpoint', 'Attempt', 'Frenchfree' | -| 'Follow' or 'TR' | 'Send', 'Attempt', 'Frenchfree' | +| 'Follow', 'TR' or 'Aid | 'Send', 'Attempt' | | 'Solo' | 'Onsight', 'Flash', 'Redpoint', 'Attempt' | | 'Boulder' | 'Flash', 'Send', 'Attempt' | -| 'Aid' | 'Send', 'Attempt' | ## A few justifications @@ -65,7 +67,7 @@ Here's a Hierarchical way to restrict values: * OB does not use the term "Fell/Hung" for roped climbs, and instead normalizes it to "Attempt", just like boulders. Importing routes from MP will convert "Fell/Hung" to "Attempt" * While 'Frenchfree' and 'Aid' could be considered synomonous, some climbers may want to distinguish, for example, a multipitch route where one pitch was intentionally 'French freed' (Time Wave Zero being a common example), which is distinctly different in character than, eg: aiding the Nose on El Cap. * Eventually, it might be cool to allow ticks for individual pitches, but that is not supported right now. -* Given the 420 possible combination, no simple logical system will perfectly capture every edge case. +* Given the 43,008 possible combinations, no simple logical system will perfectly capture every edge case. ## Importing from Mountain Project diff --git a/src/db/TickSchema.ts b/src/db/TickSchema.ts index 0510c52..a190bf0 100644 --- a/src/db/TickSchema.ts +++ b/src/db/TickSchema.ts @@ -16,8 +16,8 @@ export const TickSchema = new Schema({ notes: { type: Schema.Types.String, required: false }, climbId: { type: Schema.Types.String, required: true, index: true }, userId: { type: Schema.Types.String, required: true, index: true }, - style: { type: Schema.Types.String, enum: ['Lead', 'Solo', 'TR', 'Follow', 'Aid', 'Boulder', null], required: false }, - attemptType: { type: Schema.Types.String, enum: ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree', 'Redpoint', 'Send', 'Attempt', null], required: false, index: true }, + style: { type: Schema.Types.String, enum: ['Lead', 'Solo', 'TR', 'Follow', 'Aid', 'Boulder'], required: false }, + attemptType: { type: Schema.Types.String, enum: ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree', 'Redpoint', 'Send', 'Attempt'], required: false, index: true }, dateClimbed: { type: Schema.Types.Date }, grade: { type: Schema.Types.String, required: false, index: true }, // Bear in mind that these enum types must be kept in sync with the TickSource enum diff --git a/src/graphql/schema/Tick.gql b/src/graphql/schema/Tick.gql index 3d4e5dc..5af9b21 100644 --- a/src/graphql/schema/Tick.gql +++ b/src/graphql/schema/Tick.gql @@ -136,7 +136,6 @@ enum TickAttemptType { Send "Redpoint" Redpoint - } enum TickStyle { diff --git a/src/model/TickDataSource.ts b/src/model/TickDataSource.ts index af3bffa..5afdef7 100644 --- a/src/model/TickDataSource.ts +++ b/src/model/TickDataSource.ts @@ -86,51 +86,59 @@ export default class TickDataSource extends MongoDataSource { if (climb == null) { throw new Error('Climb not found') } - // Getting the climb singular type to verifiy, some climbs have multiple types such as the heart route on elcap (13b/v10) - const isDWSOnly = - (climb.type.deepwatersolo === true) && - Object.keys(climb.type).every( - key => key === 'deepwatersolo' || climb.type[key] === false) - - const isBoulderingOnly = - (climb.type.bouldering === true) && - Object.keys(climb.type).every( - key => key === 'bouldering' || climb.type[key] === false) - - const isTROnly = - (climb.type.tr === true) && - Object.keys(climb.type).every( - key => key === 'tr' || climb.type[key] === false) - - const isAidOnly = - (climb.type.aid === true) && - Object.keys(climb.type).every( - key => key === 'aid' || climb.type[key] === false) - - const isTradSportAlpineIceMixedAid = - ['trad', 'sport', 'alpine', 'ice', 'mixed', 'aid'].some( - type => climb.type[type] === true) - - const tickStyle = tick.style ?? 'null' // Provide a default value if tick.style is undefined + + // Tick validation logic is complicated. see [tick_logic.md](https://github.com/OpenBeta/openbeta-graphql/blob/develop/documentation/tick_logic.md). + + const tickStyle = tick.style ?? 'null' // Provide a default value if tick.style is undefined. This 'null' string is not saved in the db, but used for easy validation. const attemptType = tick.attemptType ?? 'null' // Provide a default value if tick.attempy is undefined - if (isDWSOnly || isBoulderingOnly) { // bouldering and dws can only have attempt types: 'Send', 'Flash', 'Attempt', 'Onsight' and should have no sytle - if ((['Lead', 'Solo', 'Tr', 'Follow', 'Aid'].includes(tickStyle)) || ['Pinkpoint', 'Frenchfree', 'Redpoint'].includes(attemptType)) { - throw new Error('Invalid attempt type or style for DWS/Bouldering') - } - } else if (isTROnly) { // TopRope can only have attempt types: 'Send', 'Flash', 'Attempt', 'Onsight' and styles: 'TR' - if (!['TR', 'null'].includes(tickStyle) || ['Pinkpoint', 'Frenchfree', 'Redpoint'].includes(attemptType)) { - throw new Error('Invalid attempt type or style for TR only') - } - } else if (isAidOnly) { // Aid can only have attempt types: 'Send', 'Attempt' and styles: 'Aid', 'Follow' - if (!['Aid', 'Follow', 'null'].includes(tickStyle) || ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree'].includes(attemptType)) { - throw new Error('Invalid attempt type or style for Aid only') - } - } else if (isTradSportAlpineIceMixedAid) { // roped climbs that aren't lead must have attempt types: 'Send', 'Flash', 'Attempt', 'Onsight' - if (['Solo', 'TR', 'Follow'].includes(tickStyle) && ['Pinkpoint', 'Frenchfree', 'Redpoint'].includes(attemptType)) { - throw new Error('Invalid attempt type for Solo/TR/Follow style') - } - } else { - throw new Error('Invalid climb type') + + const leadable = ['trad', 'sport', 'snow', 'ice', 'mixed', 'alpine'].some(type => climb.type[type] === true) + const topropeable = (climb.type.tr === true) || leadable + const aidable = climb.type.aid === true + const boulderable = climb.type.bouldering === true + const soloable = (climb.type.deepwatersolo === true) || leadable || aidable || (topropeable && !boulderable) + + // Validate tick style for each climb type + if (!leadable && (['Lead', 'Follow'].includes(tickStyle))) { + throw new Error(`Invalid style ${tickStyle} for climb type`) + } + if (!topropeable && (tickStyle === 'TR')) { + throw new Error(`Invalid style ${tickStyle} for climb type`) + } + if (!aidable && (tickStyle === 'Aid')) { + throw new Error(`Invalid style ${tickStyle} for climb type`) + } + if (!boulderable && (tickStyle === 'Boulder')) { + throw new Error(`Invalid style ${tickStyle} for climb type`) + } + if (!soloable && (tickStyle === 'Solo')) { + throw new Error(`Invalid style ${tickStyle} for climb type`) + } + + // validate attempt type for each tick style + switch (tickStyle) { + case 'Lead': + if (!['Onsight', 'Flash', 'Redpoint', 'Pinkpoint', 'Attempt', 'Frenchfree', 'null'].includes(attemptType)) { + throw new Error(`Invalid attempt type ${attemptType} for Lead style`) + } + break + case 'Solo': + if (!['Onsight', 'Flash', 'Redpoint', 'Attempt', 'null'].includes(attemptType)) { + throw new Error(`Invalid attempt type ${attemptType} for Solo style`) + } + break + case 'Boulder': + if (!['Flash', 'Send', 'Attempt', 'null'].includes(attemptType)) { + throw new Error(`Invalid attempt type ${attemptType} for Boulder style`) + } + break + case 'TR': + case 'Follow': + case 'Aid': + if (!['Send', 'Attempt', 'null'].includes(attemptType)) { + throw new Error(`Invalid attempt type ${attemptType} for TR/Follow/Aid style`) + } + break } } diff --git a/src/model/__tests__/tickValidation.ts b/src/model/__tests__/tickValidation.ts index 5359370..9859947 100644 --- a/src/model/__tests__/tickValidation.ts +++ b/src/model/__tests__/tickValidation.ts @@ -73,6 +73,7 @@ const toTestBoulder: TickInput = { notes: 'wet!', climbId: 'tbd', userId: userId.toUUID().toString(), + style: 'Boulder', attemptType: 'Flash', dateClimbed: new Date('2012-10-15'), grade: 'v4', @@ -168,21 +169,13 @@ describe('Tick Validation', () => { await expect(ticks.addTick(dwsTick)).resolves.not.toThrow() }) - it('should throw error for invalid attempt type for deep water solo climb', async () => { - const invalidDwsTick: TickInput = { - ...toTestDWS, - attemptType: 'Pinkpoint' - } - await expect(ticks.addTick(invalidDwsTick)).rejects.toThrow('Invalid attempt type or style for DWS/Bouldering') - }) - it('should throw error for invalid style for deep water solo climb', async () => { const invalidDwsTick: TickInput = { ...toTestDWS, style: 'Lead', attemptType: 'Send' } - await expect(ticks.addTick(invalidDwsTick)).rejects.toThrow('Invalid attempt type or style for DWS/Bouldering') + await expect(ticks.addTick(invalidDwsTick)).rejects.toThrow('Invalid style Lead for climb type') }) it('should validate tick for top rope climb', async () => { @@ -194,7 +187,7 @@ describe('Tick Validation', () => { ...toTestTR, attemptType: 'Pinkpoint' } - await expect(ticks.addTick(invalidTrTick)).rejects.toThrow('Invalid attempt type or style for TR only') + await expect(ticks.addTick(invalidTrTick)).rejects.toThrow('Invalid attempt type Pinkpoint for TR/Follow/Aid style') }) it('should validate tick for aid climb', async () => { @@ -206,7 +199,7 @@ describe('Tick Validation', () => { ...toTestAid, attemptType: 'Flash' } - await expect(ticks.addTick(invalidAidTick)).rejects.toThrow('Invalid attempt type or style for Aid only') + await expect(ticks.addTick(invalidAidTick)).rejects.toThrow('Invalid attempt type Flash for TR/Follow/Aid style') }) it('should throw error for invalid style for aid climb', async () => { @@ -215,7 +208,7 @@ describe('Tick Validation', () => { style: 'Lead', attemptType: 'Send' } - await expect(ticks.addTick(invalidAidTick)).rejects.toThrow('Invalid attempt type or style for Aid only') + await expect(ticks.addTick(invalidAidTick)).rejects.toThrow('Invalid style Lead for climb type') }) it('should validate tick with no attempt type', async () => {