@condorcet.vote/cef-writer 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +419 -0
- package/dist/Cef.d.ts +188 -0
- package/dist/Cef.d.ts.map +1 -0
- package/dist/Cef.js +291 -0
- package/dist/CefFormat.d.ts +57 -0
- package/dist/CefFormat.d.ts.map +1 -0
- package/dist/CefFormat.js +133 -0
- package/dist/CommentLine.d.ts +18 -0
- package/dist/CommentLine.d.ts.map +1 -0
- package/dist/CommentLine.js +27 -0
- package/dist/Exception/CefFormatException.d.ts +16 -0
- package/dist/Exception/CefFormatException.d.ts.map +1 -0
- package/dist/Exception/CefFormatException.js +19 -0
- package/dist/Exception/CefWriteException.d.ts +12 -0
- package/dist/Exception/CefWriteException.d.ts.map +1 -0
- package/dist/Exception/CefWriteException.js +13 -0
- package/dist/Exception/DuplicateCandidateException.d.ts +9 -0
- package/dist/Exception/DuplicateCandidateException.d.ts.map +1 -0
- package/dist/Exception/DuplicateCandidateException.js +8 -0
- package/dist/Exception/InvalidUtf8Exception.d.ts +13 -0
- package/dist/Exception/InvalidUtf8Exception.d.ts.map +1 -0
- package/dist/Exception/InvalidUtf8Exception.js +12 -0
- package/dist/Exception/InvalidValueException.d.ts +15 -0
- package/dist/Exception/InvalidValueException.d.ts.map +1 -0
- package/dist/Exception/InvalidValueException.js +14 -0
- package/dist/Exception/InvalidWriterStateException.d.ts +14 -0
- package/dist/Exception/InvalidWriterStateException.d.ts.map +1 -0
- package/dist/Exception/InvalidWriterStateException.js +13 -0
- package/dist/Exception/ReservedCharacterException.d.ts +12 -0
- package/dist/Exception/ReservedCharacterException.d.ts.map +1 -0
- package/dist/Exception/ReservedCharacterException.js +11 -0
- package/dist/Exception/index.d.ts +8 -0
- package/dist/Exception/index.d.ts.map +1 -0
- package/dist/Exception/index.js +7 -0
- package/dist/FileWriteTarget.d.ts +27 -0
- package/dist/FileWriteTarget.d.ts.map +1 -0
- package/dist/FileWriteTarget.js +35 -0
- package/dist/Parameter/CandidatesParameter.d.ts +20 -0
- package/dist/Parameter/CandidatesParameter.d.ts.map +1 -0
- package/dist/Parameter/CandidatesParameter.js +39 -0
- package/dist/Parameter/CustomParameter.d.ts +19 -0
- package/dist/Parameter/CustomParameter.d.ts.map +1 -0
- package/dist/Parameter/CustomParameter.js +35 -0
- package/dist/Parameter/ImplicitRankingParameter.d.ts +11 -0
- package/dist/Parameter/ImplicitRankingParameter.d.ts.map +1 -0
- package/dist/Parameter/ImplicitRankingParameter.js +16 -0
- package/dist/Parameter/NumberOfSeatsParameter.d.ts +14 -0
- package/dist/Parameter/NumberOfSeatsParameter.d.ts.map +1 -0
- package/dist/Parameter/NumberOfSeatsParameter.js +23 -0
- package/dist/Parameter/ParameterInterface.d.ts +20 -0
- package/dist/Parameter/ParameterInterface.d.ts.map +1 -0
- package/dist/Parameter/ParameterInterface.js +1 -0
- package/dist/Parameter/StandardParameter.d.ts +14 -0
- package/dist/Parameter/StandardParameter.d.ts.map +1 -0
- package/dist/Parameter/StandardParameter.js +14 -0
- package/dist/Parameter/VotingMethodsParameter.d.ts +16 -0
- package/dist/Parameter/VotingMethodsParameter.d.ts.map +1 -0
- package/dist/Parameter/VotingMethodsParameter.js +29 -0
- package/dist/Parameter/WeightAllowedParameter.d.ts +11 -0
- package/dist/Parameter/WeightAllowedParameter.d.ts.map +1 -0
- package/dist/Parameter/WeightAllowedParameter.js +16 -0
- package/dist/Parameter/index.d.ts +9 -0
- package/dist/Parameter/index.d.ts.map +1 -0
- package/dist/Parameter/index.js +7 -0
- package/dist/Ranking.d.ts +91 -0
- package/dist/Ranking.d.ts.map +1 -0
- package/dist/Ranking.js +162 -0
- package/dist/VoteLine.d.ts +156 -0
- package/dist/VoteLine.d.ts.map +1 -0
- package/dist/VoteLine.js +289 -0
- package/dist/index.browser.d.ts +17 -0
- package/dist/index.browser.d.ts.map +1 -0
- package/dist/index.browser.js +16 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.node.d.ts +19 -0
- package/dist/index.node.d.ts.map +1 -0
- package/dist/index.node.js +25 -0
- package/package.json +79 -0
- package/src/Cef.ts +405 -0
- package/src/CefFormat.ts +152 -0
- package/src/CommentLine.ts +32 -0
- package/src/Exception/CefFormatException.ts +19 -0
- package/src/Exception/CefWriteException.ts +13 -0
- package/src/Exception/DuplicateCandidateException.ts +8 -0
- package/src/Exception/InvalidUtf8Exception.ts +12 -0
- package/src/Exception/InvalidValueException.ts +14 -0
- package/src/Exception/InvalidWriterStateException.ts +13 -0
- package/src/Exception/ReservedCharacterException.ts +11 -0
- package/src/Exception/index.ts +7 -0
- package/src/FileWriteTarget.ts +42 -0
- package/src/Parameter/CandidatesParameter.ts +49 -0
- package/src/Parameter/CustomParameter.ts +45 -0
- package/src/Parameter/ImplicitRankingParameter.ts +17 -0
- package/src/Parameter/NumberOfSeatsParameter.ts +25 -0
- package/src/Parameter/ParameterInterface.ts +20 -0
- package/src/Parameter/StandardParameter.ts +13 -0
- package/src/Parameter/VotingMethodsParameter.ts +36 -0
- package/src/Parameter/WeightAllowedParameter.ts +17 -0
- package/src/Parameter/index.ts +8 -0
- package/src/Ranking.ts +197 -0
- package/src/VoteLine.ts +398 -0
- package/src/index.browser.ts +36 -0
- package/src/index.node.ts +47 -0
- package/src/index.ts +8 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import type { WriteTarget } from './Cef';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A {@link WriteTarget} backed by a real file descriptor, opened in truncating
|
|
7
|
+
* write mode. Created automatically when a filesystem path is passed to the
|
|
8
|
+
* {@link Cef} constructor in Node.js environments.
|
|
9
|
+
*
|
|
10
|
+
* **Note**: This class is only available in Node.js environments. Browser users
|
|
11
|
+
* must use {@link StringBuffer} or provide a custom {@link WriteTarget}.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* import { Cef, FileWriteTarget } from '@condorcet.vote/cef-writer';
|
|
16
|
+
*
|
|
17
|
+
* const target = new FileWriteTarget('/tmp/election.cvotes');
|
|
18
|
+
* const cef = new Cef({ file: target });
|
|
19
|
+
* // ... add parameters and votes
|
|
20
|
+
* cef.close();
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export class FileWriteTarget implements WriteTarget {
|
|
24
|
+
private readonly fd: number;
|
|
25
|
+
|
|
26
|
+
private closed = false;
|
|
27
|
+
|
|
28
|
+
public constructor(path: string) {
|
|
29
|
+
this.fd = fs.openSync(path, 'w');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public write(chunk: string): number {
|
|
33
|
+
return fs.writeSync(this.fd, Buffer.from(chunk, 'utf-8'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public close(): void {
|
|
37
|
+
if (!this.closed) {
|
|
38
|
+
fs.closeSync(this.fd);
|
|
39
|
+
this.closed = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { CefFormat } from '../CefFormat';
|
|
2
|
+
import { DuplicateCandidateException, InvalidValueException } from '../Exception';
|
|
3
|
+
import type { ParameterInterface } from './ParameterInterface';
|
|
4
|
+
import { StandardParameter } from './StandardParameter';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `#/Candidates:` parameter — declares the official list of candidates.
|
|
8
|
+
*
|
|
9
|
+
* Candidate names are written separated by `;`. With auto-format on, the
|
|
10
|
+
* separator is padded with spaces (`A ; B ; C`) for readability; otherwise the
|
|
11
|
+
* most compact form (`A;B;C`) is used.
|
|
12
|
+
*/
|
|
13
|
+
export class CandidatesParameter implements ParameterInterface {
|
|
14
|
+
public readonly candidates: readonly string[];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param candidates non-empty list of distinct candidate names
|
|
18
|
+
*
|
|
19
|
+
* @throws {CefFormatException}
|
|
20
|
+
*/
|
|
21
|
+
public constructor(candidates: readonly string[]) {
|
|
22
|
+
if (candidates.length === 0) {
|
|
23
|
+
throw new InvalidValueException('Candidates list cannot be empty.');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const seen = new Set<string>();
|
|
27
|
+
|
|
28
|
+
for (const candidate of candidates) {
|
|
29
|
+
const trimmed = candidate.trim();
|
|
30
|
+
CefFormat.assertValueIsClean(trimmed, 'Candidate name');
|
|
31
|
+
|
|
32
|
+
if (seen.has(trimmed)) {
|
|
33
|
+
throw new DuplicateCandidateException(`Duplicate candidate "${trimmed}".`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
seen.add(trimmed);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.candidates = candidates.map((candidate) => candidate.trim());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public getName(): string {
|
|
43
|
+
return StandardParameter.Candidates;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public getFormattedValue(autoFormat = true): string {
|
|
47
|
+
return this.candidates.join(autoFormat ? ' ; ' : ';');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { CefFormat } from '../CefFormat';
|
|
2
|
+
import { InvalidValueException, ReservedCharacterException } from '../Exception';
|
|
3
|
+
import type { ParameterInterface } from './ParameterInterface';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Free-form parameter for tooling that extends CEF with project-specific keys.
|
|
7
|
+
*
|
|
8
|
+
* The name must avoid every reserved character and `:`, since `:` separates a
|
|
9
|
+
* parameter from its value. The value must avoid line breaks but is otherwise
|
|
10
|
+
* free-form (the spec only reserves characters for *structured* values).
|
|
11
|
+
*/
|
|
12
|
+
export class CustomParameter implements ParameterInterface {
|
|
13
|
+
public readonly name: string;
|
|
14
|
+
|
|
15
|
+
public readonly value: string;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @throws {CefFormatException}
|
|
19
|
+
*/
|
|
20
|
+
public constructor(name: string, value: string) {
|
|
21
|
+
const trimmedName = name.trim();
|
|
22
|
+
|
|
23
|
+
if (trimmedName === '') {
|
|
24
|
+
throw new InvalidValueException('Custom parameter name cannot be empty.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (trimmedName.includes(':')) {
|
|
28
|
+
throw new ReservedCharacterException('Custom parameter name cannot contain ":".');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
CefFormat.assertValueIsClean(trimmedName, 'Custom parameter name');
|
|
32
|
+
CefFormat.assertNoReservedNorLineBreak(value, 'Custom parameter value');
|
|
33
|
+
|
|
34
|
+
this.name = trimmedName;
|
|
35
|
+
this.value = value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public getName(): string {
|
|
39
|
+
return this.name;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public getFormattedValue(_autoFormat = true): string {
|
|
43
|
+
return this.value;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ParameterInterface } from './ParameterInterface';
|
|
2
|
+
import { StandardParameter } from './StandardParameter';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `#/Implicit Ranking:` parameter — boolean toggle.
|
|
6
|
+
*/
|
|
7
|
+
export class ImplicitRankingParameter implements ParameterInterface {
|
|
8
|
+
public constructor(public readonly enabled: boolean) {}
|
|
9
|
+
|
|
10
|
+
public getName(): string {
|
|
11
|
+
return StandardParameter.ImplicitRanking;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public getFormattedValue(_autoFormat = true): string {
|
|
15
|
+
return this.enabled ? 'true' : 'false';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { InvalidValueException } from '../Exception';
|
|
2
|
+
import type { ParameterInterface } from './ParameterInterface';
|
|
3
|
+
import { StandardParameter } from './StandardParameter';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `#/Number of Seats:` parameter — strictly positive integer.
|
|
7
|
+
*/
|
|
8
|
+
export class NumberOfSeatsParameter implements ParameterInterface {
|
|
9
|
+
/**
|
|
10
|
+
* @throws {CefFormatException}
|
|
11
|
+
*/
|
|
12
|
+
public constructor(public readonly seats: number) {
|
|
13
|
+
if (!Number.isInteger(seats) || seats < 1) {
|
|
14
|
+
throw new InvalidValueException('Number of seats must be a positive integer.');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public getName(): string {
|
|
19
|
+
return StandardParameter.NumberOfSeats;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public getFormattedValue(_autoFormat = true): string {
|
|
23
|
+
return String(this.seats);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract every parameter object (standard or custom) must implement.
|
|
3
|
+
*
|
|
4
|
+
* A parameter knows how to render its own *name* and *value* but not how to
|
|
5
|
+
* assemble the final line: line assembly is the responsibility of the writer,
|
|
6
|
+
* which decides spacing based on the `autoFormat` flag.
|
|
7
|
+
*/
|
|
8
|
+
export interface ParameterInterface {
|
|
9
|
+
/**
|
|
10
|
+
* The parameter name, as it must appear after the `#/` prefix.
|
|
11
|
+
*/
|
|
12
|
+
getName(): string;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The parameter value, already serialized but **without** the surrounding
|
|
16
|
+
* `#/Name: ` prefix. The boolean `autoFormat` flag lets the parameter choose
|
|
17
|
+
* whether to expand its value with optional whitespace.
|
|
18
|
+
*/
|
|
19
|
+
getFormattedValue(autoFormat?: boolean): string;
|
|
20
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enumeration of the parameter names defined by the CEF specification.
|
|
3
|
+
*
|
|
4
|
+
* The string value of each case is the exact, case-correct name that must
|
|
5
|
+
* appear after the `#/` prefix in the generated file.
|
|
6
|
+
*/
|
|
7
|
+
export enum StandardParameter {
|
|
8
|
+
Candidates = 'Candidates',
|
|
9
|
+
NumberOfSeats = 'Number of Seats',
|
|
10
|
+
ImplicitRanking = 'Implicit Ranking',
|
|
11
|
+
VotingMethods = 'Voting Methods',
|
|
12
|
+
WeightAllowed = 'Weight Allowed',
|
|
13
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { CefFormat } from '../CefFormat';
|
|
2
|
+
import { InvalidValueException } from '../Exception';
|
|
3
|
+
import type { ParameterInterface } from './ParameterInterface';
|
|
4
|
+
import { StandardParameter } from './StandardParameter';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `#/Voting Methods:` parameter — list of method identifiers separated by `;`.
|
|
8
|
+
*/
|
|
9
|
+
export class VotingMethodsParameter implements ParameterInterface {
|
|
10
|
+
public readonly methods: readonly string[];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param methods non-empty list of method names
|
|
14
|
+
*
|
|
15
|
+
* @throws {CefFormatException}
|
|
16
|
+
*/
|
|
17
|
+
public constructor(methods: readonly string[]) {
|
|
18
|
+
if (methods.length === 0) {
|
|
19
|
+
throw new InvalidValueException('Voting methods list cannot be empty.');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
for (const method of methods) {
|
|
23
|
+
CefFormat.assertValueIsClean(method.trim(), 'Voting method name');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this.methods = methods.map((method) => method.trim());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public getName(): string {
|
|
30
|
+
return StandardParameter.VotingMethods;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public getFormattedValue(autoFormat = true): string {
|
|
34
|
+
return this.methods.join(autoFormat ? ' ; ' : ';');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ParameterInterface } from './ParameterInterface';
|
|
2
|
+
import { StandardParameter } from './StandardParameter';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `#/Weight Allowed:` parameter — boolean toggle.
|
|
6
|
+
*/
|
|
7
|
+
export class WeightAllowedParameter implements ParameterInterface {
|
|
8
|
+
public constructor(public readonly enabled: boolean) {}
|
|
9
|
+
|
|
10
|
+
public getName(): string {
|
|
11
|
+
return StandardParameter.WeightAllowed;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public getFormattedValue(_autoFormat = true): string {
|
|
15
|
+
return this.enabled ? 'true' : 'false';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { CandidatesParameter } from './CandidatesParameter';
|
|
2
|
+
export { CustomParameter } from './CustomParameter';
|
|
3
|
+
export { ImplicitRankingParameter } from './ImplicitRankingParameter';
|
|
4
|
+
export { NumberOfSeatsParameter } from './NumberOfSeatsParameter';
|
|
5
|
+
export type { ParameterInterface } from './ParameterInterface';
|
|
6
|
+
export { StandardParameter } from './StandardParameter';
|
|
7
|
+
export { VotingMethodsParameter } from './VotingMethodsParameter';
|
|
8
|
+
export { WeightAllowedParameter } from './WeightAllowedParameter';
|
package/src/Ranking.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { CefFormat } from './CefFormat';
|
|
2
|
+
import { DuplicateCandidateException, InvalidValueException } from './Exception';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An ordered ranking of candidates.
|
|
6
|
+
*
|
|
7
|
+
* The ranking is expressed as an ordered list of ranks; each rank is itself a
|
|
8
|
+
* list of candidate names tied at that position. An empty top-level ranking
|
|
9
|
+
* (`[]`) renders as the `/EMPTY_RANKING/` blank-ballot sentinel.
|
|
10
|
+
*
|
|
11
|
+
* A `Ranking` is immutable and self-validating: any specification violation
|
|
12
|
+
* (reserved character, empty rank, duplicate candidate) throws a
|
|
13
|
+
* {@link CefFormatException} at construction time. Render it to a CEF string
|
|
14
|
+
* with {@link format} (or by casting to `string` via {@link toString}).
|
|
15
|
+
*/
|
|
16
|
+
export class Ranking {
|
|
17
|
+
public readonly ranks: readonly (readonly string[])[];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param ranks Ordered ranks; each inner list is non-empty. Pass `[]` for
|
|
21
|
+
* the `/EMPTY_RANKING/` blank ballot.
|
|
22
|
+
*
|
|
23
|
+
* @throws {CefFormatException} on any specification violation
|
|
24
|
+
*/
|
|
25
|
+
public constructor(ranks: readonly (readonly string[])[]) {
|
|
26
|
+
this.ranks = Ranking.validate(ranks);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build a {@link Ranking} from a ranking-only string.
|
|
31
|
+
*
|
|
32
|
+
* The string may contain candidate names joined by the `>` (rank) and `=`
|
|
33
|
+
* (tie) operators, or the `/EMPTY_RANKING/` sentinel. Any reserved character
|
|
34
|
+
* (`^`, `*`, `#`, `;`, `,`, `/`), the `||` tag separator, or a line break is
|
|
35
|
+
* rejected — there is no way to smuggle a weight, quantifier, tag or inline
|
|
36
|
+
* comment through the string.
|
|
37
|
+
*
|
|
38
|
+
* @param ranking Ranking only, e.g. `"A > B = C"` or `"/EMPTY_RANKING/"`.
|
|
39
|
+
*
|
|
40
|
+
* @throws {CefFormatException}
|
|
41
|
+
*/
|
|
42
|
+
public static fromString(ranking: string): Ranking {
|
|
43
|
+
return new Ranking(Ranking.split(Ranking.normalizeString(ranking)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Validate a ranking-only string without allocating a {@link Ranking}.
|
|
48
|
+
*
|
|
49
|
+
* Runs the exact same checks as {@link fromString} (empty input, `||` tag
|
|
50
|
+
* separator, reserved characters, line breaks, duplicate candidates) but
|
|
51
|
+
* never materialises the parsed structure nor a `Ranking` instance — useful
|
|
52
|
+
* for hot paths that write the ranking string verbatim after a strict format
|
|
53
|
+
* check.
|
|
54
|
+
*
|
|
55
|
+
* @param ranking Ranking only, e.g. `"A > B = C"` or `"/EMPTY_RANKING/"`.
|
|
56
|
+
*
|
|
57
|
+
* @throws {CefFormatException}
|
|
58
|
+
*/
|
|
59
|
+
public static assertValidString(ranking: string): void {
|
|
60
|
+
const work = Ranking.normalizeString(ranking);
|
|
61
|
+
|
|
62
|
+
if (work === CefFormat.EMPTY_RANKING) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const seen = new Set<string>();
|
|
67
|
+
|
|
68
|
+
for (const rankString of work.split('>')) {
|
|
69
|
+
for (const candidate of rankString.split('=')) {
|
|
70
|
+
Ranking.assertCandidate(candidate, seen);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Trim a ranking-only string and reject the two patterns that the
|
|
77
|
+
* per-candidate validation cannot catch on its own: an empty input and the
|
|
78
|
+
* `||` tag separator. Returns the trimmed work string.
|
|
79
|
+
*
|
|
80
|
+
* @throws {CefFormatException}
|
|
81
|
+
*/
|
|
82
|
+
private static normalizeString(ranking: string): string {
|
|
83
|
+
const work = ranking.trim();
|
|
84
|
+
|
|
85
|
+
if (work === '') {
|
|
86
|
+
throw new InvalidValueException(
|
|
87
|
+
'Ranking string cannot be empty; use "/EMPTY_RANKING/" for a blank ballot.'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The "||" tag separator is the only forbidden pattern that per-candidate
|
|
92
|
+
// validation would not catch on its own ("|" is not a reserved character),
|
|
93
|
+
// so reject it explicitly here. Every reserved character and line break is
|
|
94
|
+
// rejected later, candidate by candidate.
|
|
95
|
+
CefFormat.assertNoTagSeparator(work, 'Ranking');
|
|
96
|
+
|
|
97
|
+
return work;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Render the ranking — *without* trailing newline, tags, weight, quantifier
|
|
102
|
+
* or inline comment — using the spacing flavor selected by `autoFormat`.
|
|
103
|
+
*
|
|
104
|
+
* When `autoFormat` is `true` (default), ranks are separated by `" > "` and
|
|
105
|
+
* tied candidates by `" = "`; when `false`, the most compact `>` / `=` form
|
|
106
|
+
* is emitted. An empty ranking yields the `/EMPTY_RANKING/` sentinel.
|
|
107
|
+
*/
|
|
108
|
+
public format(autoFormat = true): string {
|
|
109
|
+
if (this.ranks.length === 0) {
|
|
110
|
+
return CefFormat.EMPTY_RANKING;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const rankSep = autoFormat ? ' > ' : '>';
|
|
114
|
+
const tieSep = autoFormat ? ' = ' : '=';
|
|
115
|
+
|
|
116
|
+
return this.ranks.map((rank) => rank.join(tieSep)).join(rankSep);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Render the ranking in its relaxed (auto-formatted) flavor.
|
|
121
|
+
*/
|
|
122
|
+
public toString(): string {
|
|
123
|
+
return this.format();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Split a cleaned ranking string into its raw, *un-validated* rank/tie
|
|
128
|
+
* structure. The `/EMPTY_RANKING/` sentinel maps to an empty list. Ranks are
|
|
129
|
+
* separated by `>`, tied candidates within a rank by `=`; every token is
|
|
130
|
+
* trimmed but not otherwise checked here.
|
|
131
|
+
*/
|
|
132
|
+
private static split(work: string): string[][] {
|
|
133
|
+
if (work === CefFormat.EMPTY_RANKING) {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const rawRanking: string[][] = [];
|
|
138
|
+
|
|
139
|
+
for (const rankString of work.split('>')) {
|
|
140
|
+
const rank: string[] = [];
|
|
141
|
+
|
|
142
|
+
for (const candidate of rankString.split('=')) {
|
|
143
|
+
rank.push(candidate.trim());
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
rawRanking.push(rank);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return rawRanking;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @throws {CefFormatException}
|
|
154
|
+
*/
|
|
155
|
+
private static validate(ranks: readonly (readonly string[])[]): string[][] {
|
|
156
|
+
const cleaned: string[][] = [];
|
|
157
|
+
const seen = new Set<string>();
|
|
158
|
+
|
|
159
|
+
ranks.forEach((rank, rankIndex) => {
|
|
160
|
+
if (rank.length === 0) {
|
|
161
|
+
throw new InvalidValueException(`Rank #${String(rankIndex + 1)} is empty.`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const cleanedRank: string[] = [];
|
|
165
|
+
|
|
166
|
+
for (const candidate of rank) {
|
|
167
|
+
cleanedRank.push(Ranking.assertCandidate(candidate, seen));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
cleaned.push(cleanedRank);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return cleaned;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Trim and validate a single candidate name, rejecting reserved characters,
|
|
178
|
+
* line breaks, invalid UTF-8 and duplicates. The `seen` set is updated to
|
|
179
|
+
* detect repeats across the whole ranking.
|
|
180
|
+
*
|
|
181
|
+
* @throws {CefFormatException}
|
|
182
|
+
*/
|
|
183
|
+
private static assertCandidate(candidate: string, seen: Set<string>): string {
|
|
184
|
+
const trimmed = candidate.trim();
|
|
185
|
+
CefFormat.assertValueIsClean(trimmed, 'Ranked candidate');
|
|
186
|
+
|
|
187
|
+
if (seen.has(trimmed)) {
|
|
188
|
+
throw new DuplicateCandidateException(
|
|
189
|
+
`Candidate "${trimmed}" appears more than once in the ranking.`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
seen.add(trimmed);
|
|
194
|
+
|
|
195
|
+
return trimmed;
|
|
196
|
+
}
|
|
197
|
+
}
|