From da9aa1cf091222c092bab030ae5f45f485ec39e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:57:35 +0000 Subject: [PATCH 1/6] Initial plan From c4a37fa34ffc219c5335af2a6f2b98720e3b8bf8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:12:44 +0000 Subject: [PATCH 2/6] Fix alignment_rate processing bugs, improve mock data quality, fix og:image dimensions - coalition-dashboard.ts: Remove erroneous /100 division on alignment_rate (already 0-1) - coalition-dashboard.ts: Fix influence calculation to use *10 instead of /10 - coalition-dashboard.ts: Add meaningful mock anomaly and annual votes fallback data - party-dashboard.ts: Fix Math.round(rate) to Math.round(rate * 100) for percentage display - party-dashboard.ts: Fix momentum filter to accept zero values - Fix og:image dimensions (140x140) in all 28 index*.html and dashboard/index*.html files - Add targeted tests for alignment rate processing and mock data quality Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- dashboard/index.html | 4 +- dashboard/index_ar.html | 4 +- dashboard/index_da.html | 4 +- dashboard/index_de.html | 4 +- dashboard/index_es.html | 4 +- dashboard/index_fi.html | 4 +- dashboard/index_fr.html | 4 +- dashboard/index_he.html | 4 +- dashboard/index_ja.html | 4 +- dashboard/index_ko.html | 4 +- dashboard/index_nl.html | 4 +- dashboard/index_no.html | 4 +- dashboard/index_sv.html | 4 +- dashboard/index_zh.html | 4 +- index.html | 4 +- index_ar.html | 4 +- index_da.html | 4 +- index_de.html | 4 +- index_es.html | 4 +- index_fi.html | 4 +- index_fr.html | 4 +- index_he.html | 4 +- index_ja.html | 4 +- index_ko.html | 4 +- index_nl.html | 4 +- index_no.html | 4 +- index_sv.html | 4 +- index_zh.html | 4 +- src/browser/dashboards/coalition-dashboard.ts | 44 +++++++++-- src/browser/dashboards/party-dashboard.ts | 4 +- tests/coalition-dashboard.test.js | 75 +++++++++++++++++++ tests/party-dashboard.test.js | 48 ++++++++++++ 32 files changed, 220 insertions(+), 63 deletions(-) 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..63cfbc8b96 100644 --- a/src/browser/dashboards/coalition-dashboard.ts +++ b/src/browser/dashboards/coalition-dashboard.ts @@ -264,7 +264,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 +274,7 @@ 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 strength = alignment && alignment[source.id] && alignment[source.id][target.id] ? alignment[source.id][target.id] : 0.5; links.push({ source: source.id, target: target.id, strength }); } }); @@ -348,7 +348,7 @@ 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 alignmentVal = party1 === party2 ? 1.0 : ((dataCache.coalitionAlignment?.[party1]?.[party2]) ? dataCache.coalitionAlignment[party1][party2] : 0.5); heatMapData.push({ party1, party2, alignment: alignmentVal }); }); }); @@ -540,8 +540,42 @@ function generateMockBehavioralData(): Record { } function generateMockDecisionData(): any[] { return []; } -function generateMockAnomalyData(): AnomalyEntry[] { return []; } -function generateMockAnnualVotesData(): Record { return {}; } + +function generateMockAnomalyData(): AnomalyEntry[] { + // Provide realistic fallback data when CIA anomaly data is unavailable + const parties = Object.keys(PARTIES); + const anomalies: AnomalyEntry[] = []; + parties.forEach(party => { + const deviation = 0.5 + Math.random() * 3; + if (deviation > 1.0) { + anomalies.push({ + party, + date: '2024-06-15', + deviation: parseFloat(deviation.toFixed(2)), + severity: deviation > 3 ? 'critical' : deviation > 2 ? 'major' : 'minor' + }); + } + }); + return anomalies; +} + +function generateMockAnnualVotesData(): Record { + // Provide realistic 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++) { + const variation = 0.8 + Math.random() * 0.4; + 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..b9a8318cc5 100644 --- a/src/browser/dashboards/party-dashboard.ts +++ b/src/browser/dashboards/party-dashboard.ts @@ -1026,7 +1026,7 @@ function createCoalitionNetwork(data: CSVRow[]): void { coalitions.push({ name: `${party1Label} + ${party2Label}`, - strength: Math.round(rate), + strength: Math.round(rate * 100), parties: [row.party1, row.party2], likelihood: row.coalition_likelihood ?? 'UNKNOWN', }); @@ -1105,7 +1105,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..c8a2a1c4ee 100644 --- a/tests/coalition-dashboard.test.js +++ b/tests/coalition-dashboard.test.js @@ -343,4 +343,79 @@ 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: alignment_rate is already 0-1 (e.g., 0.84 = 84%) + const alignment = { 'M': { 'KD': 0.84 }, 'S': { 'MP': 0.72 } }; + + // Network strength should use raw value (not /100) + const strength = alignment['M']['KD']; + 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['S']['MP']; + expect(heatMapValue).toBe(0.72); + expect(heatMapValue * 100).toBeCloseTo(72); // Display as percentage + }); + + 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); + }); + }); + + describe('Mock Data Quality', () => { + it('should generate non-empty mock anomaly data', () => { + // Mock anomaly data should provide fallback visualization + const parties = ['S', 'M', 'SD', 'V', 'MP', 'C', 'L', 'KD']; + const anomalies = []; + parties.forEach(party => { + const deviation = 0.5 + 2.0; // Simulated fixed deviation for test + if (deviation > 1.0) { + anomalies.push({ party, date: '2024-06-15', deviation, severity: 'major' }); + } + }); + expect(anomalies.length).toBeGreaterThan(0); + expect(anomalies[0]).toHaveProperty('party'); + expect(anomalies[0]).toHaveProperty('deviation'); + }); + + it('should generate non-empty mock annual votes data', () => { + const parties = ['S', 'M', 'SD', 'V', 'MP', 'C', 'L', 'KD']; + const data = {}; + parties.forEach(party => { + data[party] = []; + for (let year = 2002; year <= 2025; year++) { + data[party].push({ year, votes: Math.round(15000 * 0.9) }); + } + }); + 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'); + }); + }); }); diff --git a/tests/party-dashboard.test.js b/tests/party-dashboard.test.js index 6c81c2f21f..806ca6680e 100644 --- a/tests/party-dashboard.test.js +++ b/tests/party-dashboard.test.js @@ -361,4 +361,52 @@ describe('Party Dashboard', () => { expect(containers.length).toBe(1); }); }); + + describe('Coalition Alignment Rate Processing', () => { + it('should convert 0-1 alignment_rate to percentage for display', () => { + // Real CSV: alignment_rate is 0.84 (meaning 84%) + const rate = 0.84; + const displayStrength = Math.round(rate * 100); + expect(displayStrength).toBe(84); + }); + + it('should NOT show alignment as 1% when rate is 0.84', () => { + // Bug fix: Math.round(0.84) = 1, but Math.round(0.84 * 100) = 84 + const rate = 0.84; + const wrongResult = Math.round(rate); // This was the old bug + const correctResult = Math.round(rate * 100); + + expect(wrongResult).toBe(1); // Old buggy result + expect(correctResult).toBe(84); // Correct result + }); + + it('should handle various alignment rates correctly', () => { + const testCases = [ + { rate: 0.84, expected: 84 }, + { rate: 0.72, expected: 72 }, + { rate: 0.53, expected: 53 }, + { rate: 0.35, expected: 35 }, + ]; + testCases.forEach(({ rate, expected }) => { + expect(Math.round(rate * 100)).toBe(expected); + }); + }); + + it('should handle momentum filter with zero values', () => { + // Bug fix: filter should not reject rows where momentum is 0 + const rows = [ + { party: 'S', momentum: 0, year: '2026', quarter: '1' }, + { party: 'M', momentum: 0.5, year: '2026', quarter: '1' }, + { party: 'V', momentum: '', year: '2026', quarter: '1' }, + ]; + + // Old filter: row.momentum (rejects 0) + const oldFiltered = rows.filter(r => r.momentum); + expect(oldFiltered.length).toBe(1); // Only M + + // New filter: row.momentum !== undefined && row.momentum !== '' + const newFiltered = rows.filter(r => r.momentum !== undefined && r.momentum !== ''); + expect(newFiltered.length).toBe(2); // S and M + }); + }); }); From 1025144ebe6b42ad48f7b402ea4b70e1e386a0c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:21:40 +0000 Subject: [PATCH 3/6] Address PR review: use typeof checks for zero-alignment, make mock data deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use typeof === 'number' for alignment lookups so value 0 is not treated as missing - Replace Math.random() in mock data generators with deterministic fixed values - generateMockAnomalyData() now always returns exactly 8 entries (one per party) - generateMockAnnualVotesData() uses year-parity-based ±10% variation - Add tests for zero-alignment edge case and deterministic mock data Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- src/browser/dashboards/coalition-dashboard.ts | 37 +++++++------- tests/coalition-dashboard.test.js | 51 ++++++++++++++----- 2 files changed, 56 insertions(+), 32 deletions(-) diff --git a/src/browser/dashboards/coalition-dashboard.ts b/src/browser/dashboards/coalition-dashboard.ts index 63cfbc8b96..82fb0068b2 100644 --- a/src/browser/dashboards/coalition-dashboard.ts +++ b/src/browser/dashboards/coalition-dashboard.ts @@ -274,7 +274,8 @@ 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] : 0.5; + const rawStrength = alignment?.[source.id]?.[target.id]; + const strength = typeof rawStrength === 'number' ? rawStrength : 0.5; links.push({ source: source.id, target: target.id, strength }); } }); @@ -348,7 +349,8 @@ 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] : 0.5); + const rawAlignment = dataCache.coalitionAlignment?.[party1]?.[party2]; + const alignmentVal = party1 === party2 ? 1.0 : (typeof rawAlignment === 'number' ? rawAlignment : 0.5); heatMapData.push({ party1, party2, alignment: alignmentVal }); }); }); @@ -542,25 +544,21 @@ function generateMockBehavioralData(): Record { function generateMockDecisionData(): any[] { return []; } function generateMockAnomalyData(): AnomalyEntry[] { - // Provide realistic fallback data when CIA anomaly data is unavailable - const parties = Object.keys(PARTIES); - const anomalies: AnomalyEntry[] = []; - parties.forEach(party => { - const deviation = 0.5 + Math.random() * 3; - if (deviation > 1.0) { - anomalies.push({ - party, - date: '2024-06-15', - deviation: parseFloat(deviation.toFixed(2)), - severity: deviation > 3 ? 'critical' : deviation > 2 ? 'major' : 'minor' - }); - } - }); - return anomalies; + // 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' + })); } function generateMockAnnualVotesData(): Record { - // Provide realistic fallback data for annual vote trends + // Deterministic fallback data for annual vote trends const data: Record = {}; const partyBaselines: Record = { 'S': 50000, 'M': 35000, 'SD': 25000, 'V': 12000, @@ -570,7 +568,8 @@ function generateMockAnnualVotesData(): Record { data[party] = []; const baseline = partyBaselines[party] || 15000; for (let year = 2002; year <= 2025; year++) { - const variation = 0.8 + Math.random() * 0.4; + // 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) }); } }); diff --git a/tests/coalition-dashboard.test.js b/tests/coalition-dashboard.test.js index c8a2a1c4ee..3d5e53f1e0 100644 --- a/tests/coalition-dashboard.test.js +++ b/tests/coalition-dashboard.test.js @@ -385,37 +385,62 @@ describe('Coalition Dashboard', () => { 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); + }); }); describe('Mock Data Quality', () => { - it('should generate non-empty mock anomaly data', () => { - // Mock anomaly data should provide fallback visualization - const parties = ['S', 'M', 'SD', 'V', 'MP', 'C', 'L', 'KD']; - const anomalies = []; - parties.forEach(party => { - const deviation = 0.5 + 2.0; // Simulated fixed deviation for test - if (deviation > 1.0) { - anomalies.push({ party, date: '2024-06-15', deviation, severity: 'major' }); - } - }); - expect(anomalies.length).toBeGreaterThan(0); + it('should generate deterministic non-empty mock anomaly data for all parties', () => { + // Deterministic mock deviations (no Math.random) + const deviations = { 'S': 1.85, 'M': 2.10, 'SD': 3.25, 'V': 1.45, 'MP': 2.70, 'C': 1.30, 'L': 1.95, 'KD': 2.50 }; + const parties = Object.keys(deviations); + const anomalies = parties.map(party => ({ + party, date: '2024-06-15', deviation: deviations[party], + severity: deviations[party] > 3 ? 'critical' : deviations[party] > 2 ? 'major' : 'minor' + })); + // Must always produce exactly 8 entries (one per party) + expect(anomalies.length).toBe(8); expect(anomalies[0]).toHaveProperty('party'); expect(anomalies[0]).toHaveProperty('deviation'); + // Verify deterministic: running again yields same result + const anomalies2 = parties.map(party => ({ + party, date: '2024-06-15', deviation: deviations[party], + severity: deviations[party] > 3 ? 'critical' : deviations[party] > 2 ? 'major' : 'minor' + })); + expect(anomalies).toEqual(anomalies2); }); - it('should generate non-empty mock annual votes data', () => { + it('should generate deterministic non-empty mock annual votes data', () => { const parties = ['S', 'M', 'SD', 'V', 'MP', 'C', 'L', 'KD']; + const baseline = 15000; const data = {}; parties.forEach(party => { data[party] = []; for (let year = 2002; year <= 2025; year++) { - data[party].push({ year, votes: Math.round(15000 * 0.9) }); + const variation = year % 2 === 0 ? 0.9 : 1.1; + data[party].push({ year, votes: Math.round(baseline * variation) }); } }); 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: even years get 0.9x, odd years get 1.1x + expect(data['S'][0].votes).toBe(Math.round(baseline * 0.9)); // 2002 is even + expect(data['S'][1].votes).toBe(Math.round(baseline * 1.1)); // 2003 is odd }); }); }); From 46e699aba7282edabec0aab17c4ff792cb953b91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:59:46 +0000 Subject: [PATCH 4/6] Make alignment matrix symmetric, add reverse-pair lookups, fix test fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Store both alignment[party1][party2] and alignment[party2][party1] when loading CSV (CSV has KD,M but not M,KD — symmetric matrix ensures all lookups succeed) - Add defensive reverse-pair fallback in network and heat map lookups - Update coalition test fixtures to match CSV pair direction (KD,M not M,KD) - Add test for reverse-pair lookup resolution - Update momentum test to use string values matching CSV parsing runtime types Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- src/browser/dashboards/coalition-dashboard.ts | 12 +++++++--- tests/coalition-dashboard.test.js | 24 +++++++++++++++---- tests/party-dashboard.test.js | 17 ++++++++----- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/browser/dashboards/coalition-dashboard.ts b/src/browser/dashboards/coalition-dashboard.ts index 82fb0068b2..8b10c27b96 100644 --- a/src/browser/dashboards/coalition-dashboard.ts +++ b/src/browser/dashboards/coalition-dashboard.ts @@ -162,6 +162,8 @@ async function fetchCoalitionData(): Promise { const party1 = row['party1']; const party2 = row['party2']; const alignmentRate = parseFloat(row['alignment_rate']); 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'); @@ -274,7 +276,9 @@ function renderCoalitionNetwork(): void { nodes.forEach((source, i) => { nodes.forEach((target, j) => { if (i < j) { - const rawStrength = alignment?.[source.id]?.[target.id]; + 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 }); } @@ -349,8 +353,10 @@ function renderAlignmentHeatMap(): void { const heatMapData: { party1: string; party2: string; alignment: number }[] = []; partyIds.forEach(party1 => { partyIds.forEach(party2 => { - const rawAlignment = dataCache.coalitionAlignment?.[party1]?.[party2]; - const alignmentVal = party1 === party2 ? 1.0 : (typeof rawAlignment === 'number' ? rawAlignment : 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 }); }); }); diff --git a/tests/coalition-dashboard.test.js b/tests/coalition-dashboard.test.js index 3d5e53f1e0..46c28db3b7 100644 --- a/tests/coalition-dashboard.test.js +++ b/tests/coalition-dashboard.test.js @@ -346,21 +346,37 @@ describe('Coalition Dashboard', () => { describe('Alignment Rate Data Processing', () => { it('should use alignment_rate directly as 0-1 scale without dividing by 100', () => { - // Real CSV: alignment_rate is already 0-1 (e.g., 0.84 = 84%) - const alignment = { 'M': { 'KD': 0.84 }, 'S': { 'MP': 0.72 } }; + // 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['M']['KD']; + 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['S']['MP']; + 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 diff --git a/tests/party-dashboard.test.js b/tests/party-dashboard.test.js index 806ca6680e..a08e10cbae 100644 --- a/tests/party-dashboard.test.js +++ b/tests/party-dashboard.test.js @@ -394,19 +394,24 @@ describe('Party Dashboard', () => { 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, year: '2026', quarter: '1' }, - { party: 'M', momentum: 0.5, year: '2026', quarter: '1' }, + { 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) - const oldFiltered = rows.filter(r => r.momentum); - expect(oldFiltered.length).toBe(1); // Only M + // 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 and M + expect(newFiltered.length).toBe(2); // S ('0.00') and M ('0.50') }); }); }); From 214f2f6de8cc4b2dad8c754b2af0be84198e3eb9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:27:46 +0000 Subject: [PATCH 5/6] Filter non-party rows (e.g., '-') from coalition alignment matrix CSV rows where party1 or party2 is not in PARTIES (e.g., '-' for aggregate/independent) are now skipped before storing in the symmetric alignment matrix. This prevents polluting node influence averages with non-party alignment rates. Added test verifying '-' entries are excluded. Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- src/browser/dashboards/coalition-dashboard.ts | 1 + tests/coalition-dashboard.test.js | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/browser/dashboards/coalition-dashboard.ts b/src/browser/dashboards/coalition-dashboard.ts index 8b10c27b96..d6880e0eec 100644 --- a/src/browser/dashboards/coalition-dashboard.ts +++ b/src/browser/dashboards/coalition-dashboard.ts @@ -160,6 +160,7 @@ 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] = {}; diff --git a/tests/coalition-dashboard.test.js b/tests/coalition-dashboard.test.js index 46c28db3b7..da9ea0bed2 100644 --- a/tests/coalition-dashboard.test.js +++ b/tests/coalition-dashboard.test.js @@ -416,6 +416,37 @@ describe('Coalition Dashboard', () => { 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', () => { From a5c42e46013f5439cc9df99d7d20ec1d5666da55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:55:54 +0000 Subject: [PATCH 6/6] Export mock generators and rate helper; tests call real production functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export generateMockAnomalyData() and generateMockAnnualVotesData() from coalition-dashboard.ts so tests can invoke the real generators - Export alignmentRateToPercent() from party-dashboard.ts and use it in createCoalitionNetwork() — tests now exercise the real conversion logic - Coalition mock data tests import and call the real generators, asserting determinism, non-emptiness, and known per-party baselines - Party alignment rate tests import and call alignmentRateToPercent(), ensuring the real implementation matches expected percentages Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- src/browser/dashboards/coalition-dashboard.ts | 4 +- src/browser/dashboards/party-dashboard.ts | 10 ++++- tests/coalition-dashboard.test.js | 41 ++++++++----------- tests/party-dashboard.test.js | 24 ++++++----- 4 files changed, 42 insertions(+), 37 deletions(-) diff --git a/src/browser/dashboards/coalition-dashboard.ts b/src/browser/dashboards/coalition-dashboard.ts index d6880e0eec..5b7d15e102 100644 --- a/src/browser/dashboards/coalition-dashboard.ts +++ b/src/browser/dashboards/coalition-dashboard.ts @@ -550,7 +550,7 @@ function generateMockBehavioralData(): Record { function generateMockDecisionData(): any[] { return []; } -function generateMockAnomalyData(): AnomalyEntry[] { +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, @@ -564,7 +564,7 @@ function generateMockAnomalyData(): AnomalyEntry[] { })); } -function generateMockAnnualVotesData(): Record { +export function generateMockAnnualVotesData(): Record { // Deterministic fallback data for annual vote trends const data: Record = {}; const partyBaselines: Record = { diff --git a/src/browser/dashboards/party-dashboard.ts b/src/browser/dashboards/party-dashboard.ts index b9a8318cc5..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 * 100), + strength: alignmentRateToPercent(rate), parties: [row.party1, row.party2], likelihood: row.coalition_likelihood ?? 'UNKNOWN', }); diff --git a/tests/coalition-dashboard.test.js b/tests/coalition-dashboard.test.js index da9ea0bed2..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; @@ -451,43 +452,35 @@ describe('Coalition Dashboard', () => { describe('Mock Data Quality', () => { it('should generate deterministic non-empty mock anomaly data for all parties', () => { - // Deterministic mock deviations (no Math.random) - const deviations = { 'S': 1.85, 'M': 2.10, 'SD': 3.25, 'V': 1.45, 'MP': 2.70, 'C': 1.30, 'L': 1.95, 'KD': 2.50 }; - const parties = Object.keys(deviations); - const anomalies = parties.map(party => ({ - party, date: '2024-06-15', deviation: deviations[party], - severity: deviations[party] > 3 ? 'critical' : deviations[party] > 2 ? 'major' : 'minor' - })); + 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 = parties.map(party => ({ - party, date: '2024-06-15', deviation: deviations[party], - severity: deviations[party] > 3 ? 'critical' : deviations[party] > 2 ? 'major' : 'minor' - })); + 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 parties = ['S', 'M', 'SD', 'V', 'MP', 'C', 'L', 'KD']; - const baseline = 15000; - const data = {}; - parties.forEach(party => { - data[party] = []; - for (let year = 2002; year <= 2025; year++) { - const variation = year % 2 === 0 ? 0.9 : 1.1; - data[party].push({ year, votes: Math.round(baseline * variation) }); - } - }); + 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: even years get 0.9x, odd years get 1.1x - expect(data['S'][0].votes).toBe(Math.round(baseline * 0.9)); // 2002 is even - expect(data['S'][1].votes).toBe(Math.round(baseline * 1.1)); // 2003 is odd + // 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 a08e10cbae..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; @@ -363,32 +364,35 @@ describe('Party Dashboard', () => { }); describe('Coalition Alignment Rate Processing', () => { - it('should convert 0-1 alignment_rate to percentage for display', () => { - // Real CSV: alignment_rate is 0.84 (meaning 84%) - const rate = 0.84; - const displayStrength = Math.round(rate * 100); - expect(displayStrength).toBe(84); + 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 Math.round(0.84 * 100) = 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 = Math.round(rate * 100); + const correctResult = alignmentRateToPercent(rate); expect(wrongResult).toBe(1); // Old buggy result - expect(correctResult).toBe(84); // Correct result + expect(correctResult).toBe(84); // Correct result from real implementation }); - it('should handle various alignment rates correctly', () => { + 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(Math.round(rate * 100)).toBe(expected); + expect(alignmentRateToPercent(rate)).toBe(expected); }); });