@jjlmoya/utils-civic 1.2.0 → 1.4.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/package.json +1 -1
- package/src/category/index.ts +3 -1
- package/src/entries.ts +4 -0
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/coalition-majority-calculator/bibliography.astro +6 -0
- package/src/tool/coalition-majority-calculator/bibliography.ts +12 -0
- package/src/tool/coalition-majority-calculator/coalition-majority-calculator.css +467 -0
- package/src/tool/coalition-majority-calculator/component.astro +62 -0
- package/src/tool/coalition-majority-calculator/contract.test.ts +14 -0
- package/src/tool/coalition-majority-calculator/controller.ts +89 -0
- package/src/tool/coalition-majority-calculator/dom-views.ts +145 -0
- package/src/tool/coalition-majority-calculator/entry.ts +28 -0
- package/src/tool/coalition-majority-calculator/evaluator.ts +13 -0
- package/src/tool/coalition-majority-calculator/i18n/de.ts +51 -0
- package/src/tool/coalition-majority-calculator/i18n/en.ts +74 -0
- package/src/tool/coalition-majority-calculator/i18n/es.ts +51 -0
- package/src/tool/coalition-majority-calculator/i18n/fr.ts +12 -0
- package/src/tool/coalition-majority-calculator/i18n/id.ts +45 -0
- package/src/tool/coalition-majority-calculator/i18n/it.ts +12 -0
- package/src/tool/coalition-majority-calculator/i18n/ja.ts +21 -0
- package/src/tool/coalition-majority-calculator/i18n/ko.ts +21 -0
- package/src/tool/coalition-majority-calculator/i18n/nl.ts +12 -0
- package/src/tool/coalition-majority-calculator/i18n/pl.ts +12 -0
- package/src/tool/coalition-majority-calculator/i18n/pt.ts +12 -0
- package/src/tool/coalition-majority-calculator/i18n/ru.ts +12 -0
- package/src/tool/coalition-majority-calculator/i18n/sv.ts +12 -0
- package/src/tool/coalition-majority-calculator/i18n/tr.ts +12 -0
- package/src/tool/coalition-majority-calculator/i18n/zh.ts +12 -0
- package/src/tool/coalition-majority-calculator/index.ts +11 -0
- package/src/tool/coalition-majority-calculator/logic.test.ts +60 -0
- package/src/tool/coalition-majority-calculator/logic.ts +146 -0
- package/src/tool/coalition-majority-calculator/seo.astro +12 -0
- package/src/tool/coalition-majority-calculator/storage.ts +32 -0
- package/src/tool/coalition-majority-calculator/ui.ts +80 -0
- package/src/tool/referendum-threshold-calculator/bibliography.astro +6 -0
- package/src/tool/referendum-threshold-calculator/bibliography.ts +6 -0
- package/src/tool/referendum-threshold-calculator/component.astro +117 -0
- package/src/tool/referendum-threshold-calculator/controller.ts +141 -0
- package/src/tool/referendum-threshold-calculator/dom-views.ts +71 -0
- package/src/tool/referendum-threshold-calculator/entry.ts +27 -0
- package/src/tool/referendum-threshold-calculator/evaluator.ts +19 -0
- package/src/tool/referendum-threshold-calculator/i18n/de.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/en.ts +79 -0
- package/src/tool/referendum-threshold-calculator/i18n/es.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/fr.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/id.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/it.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/ja.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/ko.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/nl.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/pl.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/pt.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/ru.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/sv.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/tr.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/zh.ts +56 -0
- package/src/tool/referendum-threshold-calculator/index.ts +11 -0
- package/src/tool/referendum-threshold-calculator/logic.test.ts +69 -0
- package/src/tool/referendum-threshold-calculator/logic.ts +120 -0
- package/src/tool/referendum-threshold-calculator/referendum-threshold-calculator.css +640 -0
- package/src/tool/referendum-threshold-calculator/seo.astro +14 -0
- package/src/tool/referendum-threshold-calculator/sharing.test.ts +23 -0
- package/src/tool/referendum-threshold-calculator/sharing.ts +42 -0
- package/src/tool/referendum-threshold-calculator/storage.ts +39 -0
- package/src/tool/referendum-threshold-calculator/ui.ts +126 -0
- package/src/tools.ts +4 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { evaluateReferendum, getExampleInput, type ReferendumInput } from './logic';
|
|
3
|
+
|
|
4
|
+
const withChanges = (changes: Partial<ReferendumInput['counts']>): ReferendumInput => ({ ...getExampleInput(), counts: { ...getExampleInput().counts, ...changes } });
|
|
5
|
+
|
|
6
|
+
describe('referendum threshold logic', () => {
|
|
7
|
+
it('passes the documented example with inclusive percentage thresholds', () => {
|
|
8
|
+
const result = evaluateReferendum(getExampleInput());
|
|
9
|
+
expect(result.status).toBe('passed');
|
|
10
|
+
expect(result.participation).toMatchObject({ numerator: 850, denominator: 1000, rate: 85, passed: true });
|
|
11
|
+
expect(result.approval).toMatchObject({ numerator: 520, denominator: 820, passed: true });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('keeps blank and invalid ballots in all ballots but not valid votes', () => {
|
|
15
|
+
const result = evaluateReferendum(withChanges({ yes: 10, no: 10, blank: 30, invalid: 50 }));
|
|
16
|
+
expect(result.totals).toEqual({ allBallots: 100, validVotes: 20, participationCount: 100 });
|
|
17
|
+
expect(result.approval.denominator).toBe(20);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('distinguishes participation failure from approval failure', () => {
|
|
21
|
+
const participation = evaluateReferendum(withChanges({ yes: 200, no: 100, blank: 10, invalid: 0 }));
|
|
22
|
+
expect(participation.status).toBe('participation-failed');
|
|
23
|
+
const approval = evaluateReferendum({ ...withChanges({ yes: 300, no: 500, blank: 20, invalid: 10 }), rules: { ...getExampleInput().rules, approval: { mode: 'percentage', value: 50, denominator: 'valid-votes' } } });
|
|
24
|
+
expect(approval.status).toBe('approval-failed');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('supports absolute participation and approval thresholds', () => {
|
|
28
|
+
const input = getExampleInput();
|
|
29
|
+
input.rules = { participation: { mode: 'absolute', value: 800, denominator: 'all-ballots' }, approval: { mode: 'absolute', value: 500, denominator: 'valid-votes' } };
|
|
30
|
+
const result = evaluateReferendum(input);
|
|
31
|
+
expect(result.status).toBe('passed');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('supports approval thresholds over the electorate and strict majorities', () => {
|
|
35
|
+
const input = getExampleInput();
|
|
36
|
+
input.rules.approval = { mode: 'percentage', value: 50, denominator: 'eligible-voters', comparison: 'more-than' };
|
|
37
|
+
const result = evaluateReferendum(input);
|
|
38
|
+
expect(result.approval).toMatchObject({ numerator: 520, denominator: 1000, rate: 52, passed: true });
|
|
39
|
+
input.counts.yes = 500;
|
|
40
|
+
input.counts.no = 300;
|
|
41
|
+
expect(evaluateReferendum(input).approval.passed).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('fails safely when approval has no denominator', () => {
|
|
45
|
+
const result = evaluateReferendum(withChanges({ yes: 0, no: 0, blank: 20, invalid: 5 }));
|
|
46
|
+
expect(result.valid).toBe(true);
|
|
47
|
+
expect(result.approval.rate).toBe(0);
|
|
48
|
+
expect(result.approval.passed).toBe(false);
|
|
49
|
+
expect(result.warnings).toContain('no-valid-votes');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects impossible totals and invalid threshold values', () => {
|
|
53
|
+
const impossible = evaluateReferendum(withChanges({ eligibleVoters: 10, yes: 20 }));
|
|
54
|
+
expect(impossible.status).toBe('invalid');
|
|
55
|
+
expect(impossible.errors).toContain('ballots-exceed-eligible');
|
|
56
|
+
const invalidThreshold = evaluateReferendum({ ...getExampleInput(), rules: { ...getExampleInput().rules, approval: { mode: 'percentage', value: 101, denominator: 'valid-votes' } } });
|
|
57
|
+
expect(invalidThreshold.errors).toContain('invalid-percentage');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('treats zero thresholds as valid only when the required denominator exists', () => {
|
|
61
|
+
const input = getExampleInput();
|
|
62
|
+
input.rules = { participation: { mode: 'percentage', value: 0, denominator: 'all-ballots' }, approval: { mode: 'percentage', value: 0, denominator: 'valid-votes' } };
|
|
63
|
+
const result = evaluateReferendum(input);
|
|
64
|
+
expect(result.participation.passed).toBe(true);
|
|
65
|
+
expect(result.approval.passed).toBe(true);
|
|
66
|
+
const empty = evaluateReferendum({ ...input, counts: { eligibleVoters: 100, yes: 0, no: 0, blank: 0, invalid: 0 } });
|
|
67
|
+
expect(empty.approval.passed).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export type ThresholdMode = 'percentage' | 'absolute';
|
|
2
|
+
export type DenominatorMode = 'all-ballots' | 'valid-votes' | 'eligible-voters';
|
|
3
|
+
export type ThresholdComparison = 'at-least' | 'more-than';
|
|
4
|
+
|
|
5
|
+
export interface BallotCounts {
|
|
6
|
+
eligibleVoters: number;
|
|
7
|
+
yes: number;
|
|
8
|
+
no: number;
|
|
9
|
+
blank: number;
|
|
10
|
+
invalid: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ThresholdRule {
|
|
14
|
+
mode: ThresholdMode;
|
|
15
|
+
value: number;
|
|
16
|
+
denominator: DenominatorMode;
|
|
17
|
+
comparison?: ThresholdComparison;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RuleSet {
|
|
21
|
+
participation: ThresholdRule;
|
|
22
|
+
approval: ThresholdRule;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ReferendumInput {
|
|
26
|
+
counts: BallotCounts;
|
|
27
|
+
rules: RuleSet;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Totals {
|
|
31
|
+
allBallots: number;
|
|
32
|
+
validVotes: number;
|
|
33
|
+
participationCount: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RuleEvaluation {
|
|
37
|
+
numerator: number;
|
|
38
|
+
denominator: number;
|
|
39
|
+
rate: number;
|
|
40
|
+
mode: ThresholdMode;
|
|
41
|
+
comparison: ThresholdComparison;
|
|
42
|
+
threshold: number;
|
|
43
|
+
passed: boolean;
|
|
44
|
+
denominatorMode: DenominatorMode;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ReferendumStatus = 'passed' | 'participation-failed' | 'approval-failed' | 'both-failed' | 'invalid';
|
|
48
|
+
|
|
49
|
+
export interface ReferendumEvaluation {
|
|
50
|
+
valid: boolean;
|
|
51
|
+
errors: string[];
|
|
52
|
+
warnings: string[];
|
|
53
|
+
totals: Totals;
|
|
54
|
+
participation: RuleEvaluation;
|
|
55
|
+
approval: RuleEvaluation;
|
|
56
|
+
status: ReferendumStatus;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const emptyRule: RuleEvaluation = { numerator: 0, denominator: 0, rate: 0, mode: 'percentage', comparison: 'at-least', threshold: 0, passed: false, denominatorMode: 'valid-votes' };
|
|
60
|
+
|
|
61
|
+
function isWholeNonnegative(value: number): boolean {
|
|
62
|
+
return Number.isFinite(value) && Number.isInteger(value) && value >= 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateCounts(counts: BallotCounts): string[] {
|
|
66
|
+
if (!Object.values(counts).every(isWholeNonnegative) || counts.eligibleVoters === 0) return ['invalid-counts'];
|
|
67
|
+
if (counts.yes + counts.no + counts.blank + counts.invalid > counts.eligibleVoters) return ['ballots-exceed-eligible'];
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validateRule(rule: ThresholdRule): string[] {
|
|
72
|
+
if (!Number.isFinite(rule.value) || rule.value < 0) return ['invalid-threshold'];
|
|
73
|
+
if (rule.mode === 'percentage' && rule.value > 100) return ['invalid-percentage'];
|
|
74
|
+
if (rule.mode === 'absolute' && !Number.isInteger(rule.value)) return ['invalid-absolute-threshold'];
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getDenominator(counts: BallotCounts, mode: DenominatorMode): number {
|
|
79
|
+
if (mode === 'all-ballots') return counts.yes + counts.no + counts.blank + counts.invalid;
|
|
80
|
+
if (mode === 'valid-votes') return counts.yes + counts.no;
|
|
81
|
+
return counts.eligibleVoters;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function meetsThreshold(observed: number, threshold: number, comparison: ThresholdComparison): boolean {
|
|
85
|
+
return comparison === 'more-than' ? observed > threshold : observed >= threshold;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function evaluateRule(rule: ThresholdRule, numerator: number, denominator: number): RuleEvaluation {
|
|
89
|
+
const comparison = rule.comparison ?? 'at-least';
|
|
90
|
+
const rate = denominator > 0 ? (numerator / denominator) * 100 : 0;
|
|
91
|
+
const passed = rule.mode === 'percentage' ? denominator > 0 && meetsThreshold(rate, rule.value, comparison) : meetsThreshold(numerator, rule.value, comparison);
|
|
92
|
+
return { numerator, denominator, rate, mode: rule.mode, comparison, threshold: rule.value, passed, denominatorMode: rule.denominator };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getStatus(participation: RuleEvaluation, approval: RuleEvaluation): ReferendumStatus {
|
|
96
|
+
if (participation.passed && approval.passed) return 'passed';
|
|
97
|
+
if (!participation.passed && !approval.passed) return 'both-failed';
|
|
98
|
+
return participation.passed ? 'approval-failed' : 'participation-failed';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function evaluateReferendum(input: ReferendumInput): ReferendumEvaluation {
|
|
102
|
+
const errors = [...new Set([...validateCounts(input.counts), ...validateRule(input.rules.participation), ...validateRule(input.rules.approval)])];
|
|
103
|
+
const allBallots = input.counts.yes + input.counts.no + input.counts.blank + input.counts.invalid;
|
|
104
|
+
const validVotes = input.counts.yes + input.counts.no;
|
|
105
|
+
const participationCount = getDenominator(input.counts, input.rules.participation.denominator);
|
|
106
|
+
const totals: Totals = { allBallots, validVotes, participationCount };
|
|
107
|
+
const participation = evaluateRule(input.rules.participation, participationCount, input.counts.eligibleVoters);
|
|
108
|
+
const approval = evaluateRule(input.rules.approval, input.counts.yes, getDenominator(input.counts, input.rules.approval.denominator));
|
|
109
|
+
const warnings: string[] = [];
|
|
110
|
+
if (allBallots === 0) warnings.push('no-ballots');
|
|
111
|
+
if (validVotes === 0) warnings.push('no-valid-votes');
|
|
112
|
+
if (errors.length > 0) return { valid: false, errors, warnings, totals, participation, approval, status: 'invalid' };
|
|
113
|
+
return { valid: true, errors: [], warnings, totals, participation, approval, status: getStatus(participation, approval) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function getExampleInput(): ReferendumInput {
|
|
117
|
+
return { counts: { eligibleVoters: 1000, yes: 520, no: 300, blank: 20, invalid: 10 }, rules: { participation: { mode: 'percentage', value: 50, denominator: 'all-ballots', comparison: 'at-least' }, approval: { mode: 'percentage', value: 50, denominator: 'valid-votes', comparison: 'at-least' } } };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const EMPTY_RULE_EVALUATION = emptyRule;
|