diff --git a/dashboard/index.html b/dashboard/index.html index e5591ca35f..4d51c5700a 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -42,8 +42,8 @@ - - + + diff --git a/dashboard/index_ar.html b/dashboard/index_ar.html index f2b76eb4a5..07bce9ee4c 100644 --- a/dashboard/index_ar.html +++ b/dashboard/index_ar.html @@ -39,8 +39,8 @@ - - + + diff --git a/dashboard/index_da.html b/dashboard/index_da.html index e283f7d158..07f3a73970 100644 --- a/dashboard/index_da.html +++ b/dashboard/index_da.html @@ -39,8 +39,8 @@ - - + + diff --git a/dashboard/index_de.html b/dashboard/index_de.html index 730941718e..aaf6a298b2 100644 --- a/dashboard/index_de.html +++ b/dashboard/index_de.html @@ -39,8 +39,8 @@ - - + + diff --git a/dashboard/index_es.html b/dashboard/index_es.html index f4633b54df..fab1dfa8dd 100644 --- a/dashboard/index_es.html +++ b/dashboard/index_es.html @@ -39,8 +39,8 @@ - - + + diff --git a/dashboard/index_fi.html b/dashboard/index_fi.html index 4adee7875c..5808ebe7f2 100644 --- a/dashboard/index_fi.html +++ b/dashboard/index_fi.html @@ -39,8 +39,8 @@ - - + + diff --git a/dashboard/index_fr.html b/dashboard/index_fr.html index 7ec1cab5ee..fbf489f971 100644 --- a/dashboard/index_fr.html +++ b/dashboard/index_fr.html @@ -39,8 +39,8 @@ - - + + diff --git a/dashboard/index_he.html b/dashboard/index_he.html index 7fdfd32d90..086a6624f3 100644 --- a/dashboard/index_he.html +++ b/dashboard/index_he.html @@ -39,8 +39,8 @@ - - + + diff --git a/dashboard/index_ja.html b/dashboard/index_ja.html index 887213f67f..3f25063781 100644 --- a/dashboard/index_ja.html +++ b/dashboard/index_ja.html @@ -39,8 +39,8 @@ - - + + diff --git a/dashboard/index_ko.html b/dashboard/index_ko.html index b630a44ca9..0d98a33caa 100644 --- a/dashboard/index_ko.html +++ b/dashboard/index_ko.html @@ -39,8 +39,8 @@ - - + + diff --git a/dashboard/index_nl.html b/dashboard/index_nl.html index ec5bde32aa..dcfe4c89de 100644 --- a/dashboard/index_nl.html +++ b/dashboard/index_nl.html @@ -39,8 +39,8 @@ - - + + diff --git a/dashboard/index_no.html b/dashboard/index_no.html index 6e39affb4b..b7d9adf8a5 100644 --- a/dashboard/index_no.html +++ b/dashboard/index_no.html @@ -39,8 +39,8 @@ - - + + diff --git a/dashboard/index_sv.html b/dashboard/index_sv.html index 1a7078d378..cf2933c5a3 100644 --- a/dashboard/index_sv.html +++ b/dashboard/index_sv.html @@ -39,8 +39,8 @@ - - + + diff --git a/dashboard/index_zh.html b/dashboard/index_zh.html index ecce05516b..35fb4d6ada 100644 --- a/dashboard/index_zh.html +++ b/dashboard/index_zh.html @@ -39,8 +39,8 @@ - - + + diff --git a/index.html b/index.html index a31800210e..04a316a62a 100644 --- a/index.html +++ b/index.html @@ -35,8 +35,8 @@ - - + + diff --git a/index_ar.html b/index_ar.html index 802e27c44e..8214f42b18 100644 --- a/index_ar.html +++ b/index_ar.html @@ -35,8 +35,8 @@ - - + + diff --git a/index_da.html b/index_da.html index 6568760d45..b8c4b5293b 100644 --- a/index_da.html +++ b/index_da.html @@ -35,8 +35,8 @@ - - + + diff --git a/index_de.html b/index_de.html index 8c8455875a..dfe0691586 100644 --- a/index_de.html +++ b/index_de.html @@ -35,8 +35,8 @@ - - + + diff --git a/index_es.html b/index_es.html index f78b2c1204..084bfa0949 100644 --- a/index_es.html +++ b/index_es.html @@ -35,8 +35,8 @@ - - + + diff --git a/index_fi.html b/index_fi.html index c738526a7c..c3fe24e04c 100644 --- a/index_fi.html +++ b/index_fi.html @@ -35,8 +35,8 @@ - - + + diff --git a/index_fr.html b/index_fr.html index 5568dce8fa..79046d761f 100644 --- a/index_fr.html +++ b/index_fr.html @@ -35,8 +35,8 @@ - - + + diff --git a/index_he.html b/index_he.html index bb51fed6a5..f002f94bc8 100644 --- a/index_he.html +++ b/index_he.html @@ -35,8 +35,8 @@ - - + + diff --git a/index_ja.html b/index_ja.html index f124b11d96..e187495587 100644 --- a/index_ja.html +++ b/index_ja.html @@ -35,8 +35,8 @@ - - + + diff --git a/index_ko.html b/index_ko.html index af475de997..781f2e531e 100644 --- a/index_ko.html +++ b/index_ko.html @@ -35,8 +35,8 @@ - - + + diff --git a/index_nl.html b/index_nl.html index d3cc38e5a4..5e1f9271ce 100644 --- a/index_nl.html +++ b/index_nl.html @@ -35,8 +35,8 @@ - - + + diff --git a/index_no.html b/index_no.html index 1b5319828d..11fa6b84f0 100644 --- a/index_no.html +++ b/index_no.html @@ -35,8 +35,8 @@ - - + + diff --git a/index_sv.html b/index_sv.html index 8a19f1bae4..a511deafba 100644 --- a/index_sv.html +++ b/index_sv.html @@ -35,8 +35,8 @@ - - + + diff --git a/index_zh.html b/index_zh.html index b11b5996b1..149941a4b5 100644 --- a/index_zh.html +++ b/index_zh.html @@ -35,8 +35,8 @@ - - + + diff --git a/src/browser/dashboards/coalition-dashboard.ts b/src/browser/dashboards/coalition-dashboard.ts index 3e526b1069..5b7d15e102 100644 --- a/src/browser/dashboards/coalition-dashboard.ts +++ b/src/browser/dashboards/coalition-dashboard.ts @@ -160,8 +160,11 @@ async function fetchCoalitionData(): Promise { const alignment: Record> = {}; csvData.forEach(row => { const party1 = row['party1']; const party2 = row['party2']; const alignmentRate = parseFloat(row['alignment_rate']); + if (!PARTIES[party1] || !PARTIES[party2]) return; if (!alignment[party1]) alignment[party1] = {}; alignment[party1][party2] = alignmentRate; + if (!alignment[party2]) alignment[party2] = {}; + alignment[party2][party1] = alignmentRate; }); dataCache.coalitionAlignment = alignment; logger.info('Coalition data loaded from CSV'); @@ -264,7 +267,7 @@ function renderCoalitionNetwork(): void { const alignment = dataCache.coalitionAlignment; if (alignment && alignment[id]) { const rates = Object.values(alignment[id]).filter((v): v is number => typeof v === 'number'); - influence = rates.length > 0 ? (rates.reduce((s, v) => s + v, 0) / rates.length) / 10 + 3 : 5; + influence = rates.length > 0 ? (rates.reduce((s, v) => s + v, 0) / rates.length) * 10 + 3 : 5; } return { id, name: PARTIES[id].name, fullName: PARTIES[id].fullName, color: PARTIES[id].color, influence: Math.max(5, Math.min(15, influence)) }; }); @@ -274,7 +277,10 @@ function renderCoalitionNetwork(): void { nodes.forEach((source, i) => { nodes.forEach((target, j) => { if (i < j) { - const strength = alignment && alignment[source.id] && alignment[source.id][target.id] ? alignment[source.id][target.id] / 100 : 0.5; + const rawStrengthForward = alignment?.[source.id]?.[target.id]; + const rawStrengthBackward = alignment?.[target.id]?.[source.id]; + const rawStrength = typeof rawStrengthForward === 'number' ? rawStrengthForward : (typeof rawStrengthBackward === 'number' ? rawStrengthBackward : undefined); + const strength = typeof rawStrength === 'number' ? rawStrength : 0.5; links.push({ source: source.id, target: target.id, strength }); } }); @@ -348,7 +354,10 @@ function renderAlignmentHeatMap(): void { const heatMapData: { party1: string; party2: string; alignment: number }[] = []; partyIds.forEach(party1 => { partyIds.forEach(party2 => { - const alignmentVal = party1 === party2 ? 1.0 : ((dataCache.coalitionAlignment?.[party1]?.[party2]) ? dataCache.coalitionAlignment[party1][party2] / 100 : 0.5); + const rawAlignmentDirect = dataCache.coalitionAlignment?.[party1]?.[party2]; + const rawAlignmentReverse = dataCache.coalitionAlignment?.[party2]?.[party1]; + const alignmentSource = typeof rawAlignmentDirect === 'number' ? rawAlignmentDirect : (typeof rawAlignmentReverse === 'number' ? rawAlignmentReverse : 0.5); + const alignmentVal = party1 === party2 ? 1.0 : alignmentSource; heatMapData.push({ party1, party2, alignment: alignmentVal }); }); }); @@ -540,8 +549,39 @@ function generateMockBehavioralData(): Record { } function generateMockDecisionData(): any[] { return []; } -function generateMockAnomalyData(): AnomalyEntry[] { return []; } -function generateMockAnnualVotesData(): Record { return {}; } + +export function generateMockAnomalyData(): AnomalyEntry[] { + // Deterministic fallback data when CIA anomaly data is unavailable + const deviations: Record = { + 'S': 1.85, 'M': 2.10, 'SD': 3.25, 'V': 1.45, + 'MP': 2.70, 'C': 1.30, 'L': 1.95, 'KD': 2.50 + }; + return Object.keys(PARTIES).map(party => ({ + party, + date: '2024-06-15', + deviation: deviations[party] || 1.50, + severity: (deviations[party] || 1.50) > 3 ? 'critical' : (deviations[party] || 1.50) > 2 ? 'major' : 'minor' + })); +} + +export function generateMockAnnualVotesData(): Record { + // Deterministic fallback data for annual vote trends + const data: Record = {}; + const partyBaselines: Record = { + 'S': 50000, 'M': 35000, 'SD': 25000, 'V': 12000, + 'MP': 10000, 'C': 12000, 'L': 10000, 'KD': 10000 + }; + Object.keys(PARTIES).forEach(party => { + data[party] = []; + const baseline = partyBaselines[party] || 15000; + for (let year = 2002; year <= 2025; year++) { + // Deterministic variation: alternates ±10% based on year parity + const variation = year % 2 === 0 ? 0.9 : 1.1; + data[party].push({ year, votes: Math.round(baseline * variation) }); + } + }); + return data; +} // ============================================================================ // EXPORTED INIT diff --git a/src/browser/dashboards/party-dashboard.ts b/src/browser/dashboards/party-dashboard.ts index b9f059e92f..462b2dadd2 100644 --- a/src/browser/dashboards/party-dashboard.ts +++ b/src/browser/dashboards/party-dashboard.ts @@ -994,6 +994,14 @@ function createComparisonChart(data: CSVRow[]): void { addChartKeyboardNav(chart, ctx); } +/** + * Convert a 0–1 alignment rate to a display percentage (0–100). + * Exported for testability so unit tests validate the real conversion logic. + */ +export function alignmentRateToPercent(rate: number): number { + return Math.round(rate * 100); +} + /** * Create Coalition Alignment HTML visualization. * Renders the top-6 coalition pairs as progress bars. @@ -1026,7 +1034,7 @@ function createCoalitionNetwork(data: CSVRow[]): void { coalitions.push({ name: `${party1Label} + ${party2Label}`, - strength: Math.round(rate), + strength: alignmentRateToPercent(rate), parties: [row.party1, row.party2], likelihood: row.coalition_likelihood ?? 'UNKNOWN', }); @@ -1105,7 +1113,7 @@ function createMomentumChart(data: CSVRow[]): void { const momentumData: MomentumDataPoint[] = PARTIES.map((party) => { // Filter data for this party and get most recent quarter const partyRows = data.filter( - (row) => row.party === party && row.momentum, + (row) => row.party === party && row.momentum !== undefined && row.momentum !== '', ); if (partyRows.length > 0) { diff --git a/tests/coalition-dashboard.test.js b/tests/coalition-dashboard.test.js index 22f17da0c2..a927441bda 100644 --- a/tests/coalition-dashboard.test.js +++ b/tests/coalition-dashboard.test.js @@ -6,6 +6,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { generateMockAnomalyData, generateMockAnnualVotesData } from '../src/browser/dashboards/coalition-dashboard.js'; describe('Coalition Dashboard', () => { let container; @@ -343,4 +344,143 @@ describe('Coalition Dashboard', () => { expect(container.classList.contains('loading')).toBe(false); }); }); + + describe('Alignment Rate Data Processing', () => { + it('should use alignment_rate directly as 0-1 scale without dividing by 100', () => { + // Real CSV stores pairs alphabetically (e.g., KD,M not M,KD) + const alignment = { 'KD': { 'M': 0.84 }, 'MP': { 'S': 0.72 } }; + + // Network strength should use raw value (not /100) + const strength = alignment['KD']['M']; + expect(strength).toBe(0.84); + expect(strength).toBeGreaterThan(0.5); + expect(strength).toBeLessThanOrEqual(1.0); + + // Heat map should also use raw value + const heatMapValue = alignment['MP']['S']; + expect(heatMapValue).toBe(0.72); + expect(heatMapValue * 100).toBeCloseTo(72); // Display as percentage + }); + + it('should handle reverse-pair lookups when CSV stores only one direction', () => { + // CSV has KD,M but code may look up M,KD — reverse lookup should find it + const alignment = { 'KD': { 'M': 0.84 } }; + + // Forward lookup: KD -> M (present in CSV) + const rawForward = alignment?.['KD']?.['M']; + expect(typeof rawForward === 'number').toBe(true); + expect(rawForward).toBe(0.84); + + // Reverse lookup: M -> KD (not in CSV — should check reverse) + const rawDirect = alignment?.['M']?.['KD']; + const rawReverse = alignment?.['KD']?.['M']; + const resolved = typeof rawDirect === 'number' ? rawDirect : (typeof rawReverse === 'number' ? rawReverse : 0.5); + expect(resolved).toBe(0.84); // Found via reverse lookup + }); + + it('should NOT divide alignment_rate by 100 (values are already 0-1)', () => { + // This test validates the fix: alignment_rate 0.84 should render as 84%, not 0.84% + const rawAlignmentRate = 0.84; // From CSV + + // WRONG (old behavior): dividing 0-1 value by 100 gives 0.0084 + const wrongValue = rawAlignmentRate / 100; + expect(wrongValue).toBeLessThan(0.01); // This would be incorrect + + // CORRECT (new behavior): use raw value directly + const correctValue = rawAlignmentRate; + expect(correctValue).toBeCloseTo(0.84); + expect(correctValue * 100).toBeCloseTo(84); // Display as 84% + }); + + it('should calculate node influence correctly with 0-1 alignment rates', () => { + // With alignment rates in 0-1 range, average for same-bloc parties ~0.65-0.84 + const rates = [0.84, 0.83, 0.78]; // M-KD, M-L, M-C alignment rates + const avgRate = rates.reduce((s, v) => s + v, 0) / rates.length; // ~0.817 + const influence = avgRate * 10 + 3; // ~11.17 (good range for visualization) + + expect(influence).toBeGreaterThan(5); + expect(influence).toBeLessThan(15); + expect(Math.max(5, Math.min(15, influence))).toBeCloseTo(influence); + }); + + it('should treat alignment value of 0 as valid, not fall back to 0.5', () => { + // An alignment of 0 means zero alignment — it should NOT be treated as missing + const alignment = { 'S': { 'SD': 0 } }; + const rawStrength = alignment?.['S']?.['SD']; + const strength = typeof rawStrength === 'number' ? rawStrength : 0.5; + expect(strength).toBe(0); // Must be 0, not 0.5 + }); + + it('should fall back to 0.5 only for missing alignment data', () => { + const alignment = { 'S': {} }; + const rawStrength = alignment?.['S']?.['M']; + const strength = typeof rawStrength === 'number' ? rawStrength : 0.5; + expect(strength).toBe(0.5); + }); + + it('should filter out non-party rows (e.g., party "-") when building alignment matrix', () => { + // CSV contains rows where party1 or party2 is '-' (aggregate/independent) + const PARTIES_SET = { 'S': true, 'M': true, 'SD': true, 'V': true, 'MP': true, 'C': true, 'L': true, 'KD': true }; + const csvRows = [ + { party1: 'KD', party2: 'M', alignment_rate: '0.84' }, + { party1: '-', party2: 'SD', alignment_rate: '0.39' }, + { party1: '-', party2: 'S', alignment_rate: '0.34' }, + { party1: 'S', party2: 'V', alignment_rate: '0.65' }, + ]; + + const alignment = {}; + csvRows.forEach(row => { + const p1 = row.party1; const p2 = row.party2; const rate = parseFloat(row.alignment_rate); + if (!PARTIES_SET[p1] || !PARTIES_SET[p2]) return; + if (!alignment[p1]) alignment[p1] = {}; + alignment[p1][p2] = rate; + if (!alignment[p2]) alignment[p2] = {}; + alignment[p2][p1] = rate; + }); + + // '-' should not appear as a key in the alignment matrix + expect(alignment['-']).toBeUndefined(); + // Real parties should be stored symmetrically + expect(alignment['KD']['M']).toBe(0.84); + expect(alignment['M']['KD']).toBe(0.84); + expect(alignment['S']['V']).toBe(0.65); + // '-' entries should not pollute any party's alignment map + expect(alignment['SD']?.['-']).toBeUndefined(); + expect(alignment['S']?.['-']).toBeUndefined(); + }); + }); + + describe('Mock Data Quality', () => { + it('should generate deterministic non-empty mock anomaly data for all parties', () => { + const anomalies = generateMockAnomalyData(); + // Must always produce exactly 8 entries (one per party) + expect(anomalies.length).toBe(8); + expect(anomalies[0]).toHaveProperty('party'); + expect(anomalies[0]).toHaveProperty('deviation'); + expect(anomalies[0]).toHaveProperty('severity'); + // Verify deterministic: running again yields same result + const anomalies2 = generateMockAnomalyData(); + expect(anomalies).toEqual(anomalies2); + // Verify known values from the real generator + const sdEntry = anomalies.find(a => a.party === 'SD'); + expect(sdEntry.deviation).toBe(3.25); + expect(sdEntry.severity).toBe('critical'); + }); + + it('should generate deterministic non-empty mock annual votes data', () => { + const data = generateMockAnnualVotesData(); + expect(Object.keys(data).length).toBe(8); + expect(data['S'].length).toBeGreaterThan(0); + expect(data['S'][0]).toHaveProperty('year'); + expect(data['S'][0]).toHaveProperty('votes'); + // Verify deterministic: running again yields same result + const data2 = generateMockAnnualVotesData(); + expect(data).toEqual(data2); + // Verify per-party baselines from the real generator (S=50000, not generic 15000) + const sVotes2002 = data['S'].find(v => v.year === 2002); + expect(sVotes2002.votes).toBe(Math.round(50000 * 0.9)); // even year → 0.9x + const mVotes2003 = data['M'].find(v => v.year === 2003); + expect(mVotes2003.votes).toBe(Math.round(35000 * 1.1)); // odd year → 1.1x + }); + }); }); diff --git a/tests/party-dashboard.test.js b/tests/party-dashboard.test.js index 6c81c2f21f..09779fd860 100644 --- a/tests/party-dashboard.test.js +++ b/tests/party-dashboard.test.js @@ -12,6 +12,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; +import { alignmentRateToPercent } from '../src/browser/dashboards/party-dashboard.js'; describe('Party Dashboard', () => { let container; @@ -361,4 +362,60 @@ describe('Party Dashboard', () => { expect(containers.length).toBe(1); }); }); + + describe('Coalition Alignment Rate Processing', () => { + it('should convert 0-1 alignment_rate to percentage for display using real helper', () => { + // Uses the actual exported alignmentRateToPercent() from party-dashboard.ts + expect(alignmentRateToPercent(0.84)).toBe(84); + expect(alignmentRateToPercent(0.72)).toBe(72); + expect(alignmentRateToPercent(0.53)).toBe(53); + expect(alignmentRateToPercent(0.35)).toBe(35); + }); + + it('should NOT show alignment as 1% when rate is 0.84', () => { + // Bug fix: Math.round(0.84) = 1, but alignmentRateToPercent(0.84) = 84 + const rate = 0.84; + const wrongResult = Math.round(rate); // This was the old bug + const correctResult = alignmentRateToPercent(rate); + + expect(wrongResult).toBe(1); // Old buggy result + expect(correctResult).toBe(84); // Correct result from real implementation + }); + + it('should handle various alignment rates correctly via real helper', () => { + const testCases = [ + { rate: 0.84, expected: 84 }, + { rate: 0.72, expected: 72 }, + { rate: 0.53, expected: 53 }, + { rate: 0.35, expected: 35 }, + { rate: 0, expected: 0 }, + { rate: 1, expected: 100 }, + ]; + testCases.forEach(({ rate, expected }) => { + expect(alignmentRateToPercent(rate)).toBe(expected); + }); + }); + + it('should handle momentum filter with zero values', () => { + // Bug fix: filter should not reject rows where momentum is 0 + // CSV parsing produces string values, so test with strings to match runtime types + const rows = [ + { party: 'S', momentum: '0.00', year: '2026', quarter: '1' }, + { party: 'M', momentum: '0.50', year: '2026', quarter: '1' }, + { party: 'V', momentum: '', year: '2026', quarter: '1' }, + ]; + + // Old filter: row.momentum (rejects '0.00' falsy? No, '0.00' is truthy as a string) + // But numeric 0 from parseFloat would be falsy + const numericRows = rows.map(r => ({ ...r, momentumNum: r.momentum !== '' ? parseFloat(r.momentum) : undefined })); + + // Old filter with numeric: row.momentumNum (rejects 0) + const oldFiltered = numericRows.filter(r => r.momentumNum); + expect(oldFiltered.length).toBe(1); // Only M (0.50) + + // New filter: row.momentum !== undefined && row.momentum !== '' + const newFiltered = rows.filter(r => r.momentum !== undefined && r.momentum !== ''); + expect(newFiltered.length).toBe(2); // S ('0.00') and M ('0.50') + }); + }); });