diff --git a/lib/av_client/types.ts b/lib/av_client/types.ts index 58a1b31d..86dafe45 100644 --- a/lib/av_client/types.ts +++ b/lib/av_client/types.ts @@ -407,6 +407,11 @@ export interface ContestContent { attachments?: Attachment[] } +export interface Error { + message: string, + keys?: any +} + export interface ResultType { name: string } @@ -434,6 +439,7 @@ export interface OptionContent { maxSize: number encoding: 'utf8' } + maxChooseableSuboptions?: number } export interface ParentOption { diff --git a/lib/validators/contestSelectionValidator.ts b/lib/validators/contestSelectionValidator.ts index 20d49f52..90993b06 100644 --- a/lib/validators/contestSelectionValidator.ts +++ b/lib/validators/contestSelectionValidator.ts @@ -1,4 +1,4 @@ -import { ContestContent, ContestSelection } from '../av_client/types'; +import { ContestContent, ContestSelection, Error } from '../av_client/types'; import SelectionPileValidator from './selectionPileValidator'; export default class ContestSelectionValidator { @@ -14,8 +14,8 @@ export default class ContestSelectionValidator { return this.allWeightUsed(contestSelection) && this.validate(contestSelection).length == 0; } - validate(contestSelection: ContestSelection): string[] { - let errors: string[] = []; + validate(contestSelection: ContestSelection): Error[] { + let errors: Error[] = []; const selectionPileValidator = new SelectionPileValidator(this.contest); contestSelection.piles.forEach((pile) => { @@ -23,7 +23,7 @@ export default class ContestSelectionValidator { }); contestSelection.piles.forEach((pile) => { - if (!selectionPileValidator.isComplete(pile)) errors.push('A selection is not complete'); + if (!selectionPileValidator.isComplete(pile)) errors.push({ message: 'A selection is not complete' }); }); return errors; diff --git a/lib/validators/selectionPileValidator.ts b/lib/validators/selectionPileValidator.ts index 8dae6bb5..75935a25 100644 --- a/lib/validators/selectionPileValidator.ts +++ b/lib/validators/selectionPileValidator.ts @@ -1,4 +1,4 @@ -import { ContestContent, OptionSelection, OptionContent, SelectionPile } from '../av_client/types'; +import { ContestContent, OptionSelection, OptionContent, SelectionPile, Error } from '../av_client/types'; class SelectionPileValidator { private contest: ContestContent; @@ -6,13 +6,15 @@ class SelectionPileValidator { this.contest = contest; } - validate(selectionPile: SelectionPile): string[] { - const errors: string[] = []; + validate(selectionPile: SelectionPile): Error[] { + const errors: Error[] = []; - if (this.referenceMissing(selectionPile.optionSelections)) errors.push('invalid_reference'); - if (this.tooManySelections(selectionPile.optionSelections)) errors.push('too_many'); - if (this.blankNotAlone(selectionPile.optionSelections, selectionPile.explicitBlank)) errors.push('blank'); - if (this.exclusiveNotAlone(selectionPile.optionSelections)) errors.push('exclusive'); + if (this.referenceMissing(selectionPile.optionSelections)) errors.push({ message: 'invalid_reference'}); + if (this.tooManySelections(selectionPile.optionSelections)) errors.push({ message: 'too_many'}); + if (this.blankNotAlone(selectionPile.optionSelections, selectionPile.explicitBlank)) errors.push({message: 'blank'}); + if (this.exclusiveNotAlone(selectionPile.optionSelections)) errors.push({ message: 'exclusive' }); + + errors.push(...this.exceededListVotes(selectionPile.optionSelections)) return errors; } @@ -58,6 +60,38 @@ class SelectionPileValidator { return choices.length > this.contest.markingType.maxMarks; } + private exceededListVotes(choices: OptionSelection[]) { + const options = this.recursiveFlattener(this.contest.options as OptionContent[]); + + const optionsWithListLimit = options.map((op) => op?.maxChooseableSuboptions ? op : null) + + const errors: Error[] = [] + + optionsWithListLimit.forEach(op => { + if (op?.maxChooseableSuboptions && this.selectedChildren(choices, [op]) > op.maxChooseableSuboptions) { + errors.push({message: "exceeded_list_limit", keys: { list_name: op.title, max_list_marks: op.maxChooseableSuboptions }}) + } + }) + + return errors + } + + private selectedChildren(choices: OptionSelection[], options?: OptionContent[], count = 0): number { + if (!options) return count + + options.forEach(op => { + const childrenSelected = op?.children?.filter(child => this.selectedReferences(choices).includes(child.reference)) + + count += childrenSelected?.length || 0 + + if (op.children) { + count = this.selectedChildren(choices, op.children, count) + } + }) + + return count + } + private exclusiveNotAlone(choices: OptionSelection[]) { if (this.selectedReferences(choices).length < 2) return false; diff --git a/package.json b/package.json index 161840df..56858cb2 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "3.2.0", + "version": "4.0.0", "name": "@aion-dk/js-client", "license": "MIT", "description": "Assembly Voting JS client", diff --git a/test/selection_pile_validator.test.ts b/test/selection_pile_validator.test.ts new file mode 100644 index 00000000..28a22821 --- /dev/null +++ b/test/selection_pile_validator.test.ts @@ -0,0 +1,179 @@ +import SelectionPileValidator from "../lib/validators/selectionPileValidator"; + +import { expect } from "chai"; +import { + ContestConfig, +} from "../lib/av_client/types"; + +const contestOne: ContestConfig = { + address: "", + author: "", + parentAddress: "", + previousAddress: "", + registeredAt: "", + signature: "", + type: "ContestConfigItem", + content: { + reference: "contest-1", + markingType: { + minMarks: 1, + maxMarks: 3, + blankSubmission: "disabled", + encoding: { + codeSize: 1, + maxSize: 1, + cryptogramCount: 1, + }, + }, + resultType: { + name: "does not matter right now", + }, + title: { en: "Contest 1" }, + subtitle: { en: "Contest 1" }, + description: { en: "Contest 1" }, + options: [ + { + reference: "parent-1", + code: 1, + title: { en: "Parent 1" }, + subtitle: { en: "Parent 1" }, + description: { en: "Parent 1" }, + maxChooseableSuboptions: 2, + children: [ + { + reference: "child-1", + code: 11, + title: { en: "Child 1" }, + subtitle: { en: "Child 1" }, + description: { en: "Child 1" }, + }, + { + reference: "child-2", + code: 12, + title: { en: "Child 2" }, + subtitle: { en: "Child 2" }, + description: { en: "Child 2" }, + }, + { + reference: "child-3", + code: 13, + title: { en: "Child 3" }, + subtitle: { en: "Child 3" }, + description: { en: "Child 3" }, + } + ] + }, + { + reference: "parent-2", + code: 3, + title: { en: "Parent 2" }, + subtitle: { en: "Parent 2" }, + description: { en: "Parent 2" }, + exclusive: true, + }, + { + reference: "parent-3", + code: 4, + title: { en: "Parent 3" }, + subtitle: { en: "Parent 3" }, + description: { en: "Parent 3" }, + }, + ], + }, +}; + +const optionSelections = [ + { reference: "parent-1" } +] + +const selectionPile = { + multiplier: 1, + optionSelections: optionSelections, + explicitBlank: false +} + +const validator = new SelectionPileValidator(contestOne.content) + +describe("validate", () => { + context("when given a valid selectionPile", () => { + it("returns no errors", () => { + expect(validator.validate(selectionPile)).to.have.lengthOf(0) + }); + }); + + context("when given too many selections", () => { + const optionSelections = [ + { reference: "parent-1" }, + { reference: "parent-3" }, + { reference: "child-2" }, + { reference: "child-3" } + ] + + const selectionPile = { + multiplier: 1, + optionSelections: optionSelections, + explicitBlank: false + } + + it("returns 'too_many' error", () => { + expect(validator.validate(selectionPile)).to.have.lengthOf(1) + expect(validator.validate(selectionPile)[0].message).to.equal("too_many") + }); + }) + + context("with invalid reference", () => { + const optionSelections = [{ reference: "invalid"}] + const selectionPile = { + multiplier: 1, + optionSelections: optionSelections, + explicitBlank: false + } + + it("returns 'invalid_reference' error", () => { + expect(validator.validate(selectionPile)).to.have.lengthOf(1) + expect(validator.validate(selectionPile)[0].message).to.equal("invalid_reference") + }); + }) + + context("with blank not exclusive reference", () => { + const optionSelections = [ { reference: "blank" }, { reference: "parent-1" }] + const selectionPile = { + multiplier: 1, + optionSelections: optionSelections, + explicitBlank: true + } + + it("returns 'blank' error", () => { + expect(validator.validate(selectionPile)).to.have.lengthOf(1) + expect(validator.validate(selectionPile)[0].message).to.equal("blank") + }); + }) + + context("with exclusive not exclusive reference", () => { + const optionSelections = [ { reference: "parent-1" }, { reference: "parent-2" }] + const selectionPile = { + multiplier: 1, + optionSelections: optionSelections, + explicitBlank: false + } + + it("returns 'exclusive' error", () => { + expect(validator.validate(selectionPile)).to.have.lengthOf(1) + expect(validator.validate(selectionPile)[0].message).to.equal("exclusive") + }); + }) + + context("with exceeded list limit", () => { + const optionSelections = [ { reference: "child-1" }, { reference: "child-2" }, { reference: "child-3" } ] + const selectionPile = { + multiplier: 1, + optionSelections: optionSelections, + explicitBlank: false + } + + it("returns 'exceeded_list_limit' error", () => { + expect(validator.validate(selectionPile)).to.have.lengthOf(1) + expect(validator.validate(selectionPile)[0].message).to.equal("exceeded_list_limit") + }); + }) +});