@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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +419 -0
  3. package/dist/Cef.d.ts +188 -0
  4. package/dist/Cef.d.ts.map +1 -0
  5. package/dist/Cef.js +291 -0
  6. package/dist/CefFormat.d.ts +57 -0
  7. package/dist/CefFormat.d.ts.map +1 -0
  8. package/dist/CefFormat.js +133 -0
  9. package/dist/CommentLine.d.ts +18 -0
  10. package/dist/CommentLine.d.ts.map +1 -0
  11. package/dist/CommentLine.js +27 -0
  12. package/dist/Exception/CefFormatException.d.ts +16 -0
  13. package/dist/Exception/CefFormatException.d.ts.map +1 -0
  14. package/dist/Exception/CefFormatException.js +19 -0
  15. package/dist/Exception/CefWriteException.d.ts +12 -0
  16. package/dist/Exception/CefWriteException.d.ts.map +1 -0
  17. package/dist/Exception/CefWriteException.js +13 -0
  18. package/dist/Exception/DuplicateCandidateException.d.ts +9 -0
  19. package/dist/Exception/DuplicateCandidateException.d.ts.map +1 -0
  20. package/dist/Exception/DuplicateCandidateException.js +8 -0
  21. package/dist/Exception/InvalidUtf8Exception.d.ts +13 -0
  22. package/dist/Exception/InvalidUtf8Exception.d.ts.map +1 -0
  23. package/dist/Exception/InvalidUtf8Exception.js +12 -0
  24. package/dist/Exception/InvalidValueException.d.ts +15 -0
  25. package/dist/Exception/InvalidValueException.d.ts.map +1 -0
  26. package/dist/Exception/InvalidValueException.js +14 -0
  27. package/dist/Exception/InvalidWriterStateException.d.ts +14 -0
  28. package/dist/Exception/InvalidWriterStateException.d.ts.map +1 -0
  29. package/dist/Exception/InvalidWriterStateException.js +13 -0
  30. package/dist/Exception/ReservedCharacterException.d.ts +12 -0
  31. package/dist/Exception/ReservedCharacterException.d.ts.map +1 -0
  32. package/dist/Exception/ReservedCharacterException.js +11 -0
  33. package/dist/Exception/index.d.ts +8 -0
  34. package/dist/Exception/index.d.ts.map +1 -0
  35. package/dist/Exception/index.js +7 -0
  36. package/dist/FileWriteTarget.d.ts +27 -0
  37. package/dist/FileWriteTarget.d.ts.map +1 -0
  38. package/dist/FileWriteTarget.js +35 -0
  39. package/dist/Parameter/CandidatesParameter.d.ts +20 -0
  40. package/dist/Parameter/CandidatesParameter.d.ts.map +1 -0
  41. package/dist/Parameter/CandidatesParameter.js +39 -0
  42. package/dist/Parameter/CustomParameter.d.ts +19 -0
  43. package/dist/Parameter/CustomParameter.d.ts.map +1 -0
  44. package/dist/Parameter/CustomParameter.js +35 -0
  45. package/dist/Parameter/ImplicitRankingParameter.d.ts +11 -0
  46. package/dist/Parameter/ImplicitRankingParameter.d.ts.map +1 -0
  47. package/dist/Parameter/ImplicitRankingParameter.js +16 -0
  48. package/dist/Parameter/NumberOfSeatsParameter.d.ts +14 -0
  49. package/dist/Parameter/NumberOfSeatsParameter.d.ts.map +1 -0
  50. package/dist/Parameter/NumberOfSeatsParameter.js +23 -0
  51. package/dist/Parameter/ParameterInterface.d.ts +20 -0
  52. package/dist/Parameter/ParameterInterface.d.ts.map +1 -0
  53. package/dist/Parameter/ParameterInterface.js +1 -0
  54. package/dist/Parameter/StandardParameter.d.ts +14 -0
  55. package/dist/Parameter/StandardParameter.d.ts.map +1 -0
  56. package/dist/Parameter/StandardParameter.js +14 -0
  57. package/dist/Parameter/VotingMethodsParameter.d.ts +16 -0
  58. package/dist/Parameter/VotingMethodsParameter.d.ts.map +1 -0
  59. package/dist/Parameter/VotingMethodsParameter.js +29 -0
  60. package/dist/Parameter/WeightAllowedParameter.d.ts +11 -0
  61. package/dist/Parameter/WeightAllowedParameter.d.ts.map +1 -0
  62. package/dist/Parameter/WeightAllowedParameter.js +16 -0
  63. package/dist/Parameter/index.d.ts +9 -0
  64. package/dist/Parameter/index.d.ts.map +1 -0
  65. package/dist/Parameter/index.js +7 -0
  66. package/dist/Ranking.d.ts +91 -0
  67. package/dist/Ranking.d.ts.map +1 -0
  68. package/dist/Ranking.js +162 -0
  69. package/dist/VoteLine.d.ts +156 -0
  70. package/dist/VoteLine.d.ts.map +1 -0
  71. package/dist/VoteLine.js +289 -0
  72. package/dist/index.browser.d.ts +17 -0
  73. package/dist/index.browser.d.ts.map +1 -0
  74. package/dist/index.browser.js +16 -0
  75. package/dist/index.d.ts +8 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +7 -0
  78. package/dist/index.node.d.ts +19 -0
  79. package/dist/index.node.d.ts.map +1 -0
  80. package/dist/index.node.js +25 -0
  81. package/package.json +79 -0
  82. package/src/Cef.ts +405 -0
  83. package/src/CefFormat.ts +152 -0
  84. package/src/CommentLine.ts +32 -0
  85. package/src/Exception/CefFormatException.ts +19 -0
  86. package/src/Exception/CefWriteException.ts +13 -0
  87. package/src/Exception/DuplicateCandidateException.ts +8 -0
  88. package/src/Exception/InvalidUtf8Exception.ts +12 -0
  89. package/src/Exception/InvalidValueException.ts +14 -0
  90. package/src/Exception/InvalidWriterStateException.ts +13 -0
  91. package/src/Exception/ReservedCharacterException.ts +11 -0
  92. package/src/Exception/index.ts +7 -0
  93. package/src/FileWriteTarget.ts +42 -0
  94. package/src/Parameter/CandidatesParameter.ts +49 -0
  95. package/src/Parameter/CustomParameter.ts +45 -0
  96. package/src/Parameter/ImplicitRankingParameter.ts +17 -0
  97. package/src/Parameter/NumberOfSeatsParameter.ts +25 -0
  98. package/src/Parameter/ParameterInterface.ts +20 -0
  99. package/src/Parameter/StandardParameter.ts +13 -0
  100. package/src/Parameter/VotingMethodsParameter.ts +36 -0
  101. package/src/Parameter/WeightAllowedParameter.ts +17 -0
  102. package/src/Parameter/index.ts +8 -0
  103. package/src/Ranking.ts +197 -0
  104. package/src/VoteLine.ts +398 -0
  105. package/src/index.browser.ts +36 -0
  106. package/src/index.node.ts +47 -0
  107. 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
+ }