diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 739540ac..b1f7fd39 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -46,14 +46,26 @@ import type { PIDMetricsSummary, TransferFunctionMetricsSummary, } from '@shared/types/tuning-history.types'; +import type { DataQualityScore } from '@shared/types/analysis.types'; import { extractFilterMetrics, extractPIDMetrics, extractTransferFunctionMetrics, } from '@shared/utils/metricsExtract'; import type { TuningAction } from './components/TuningStatusBanner/TuningStatusBanner'; +import { VerificationQualityWarning } from './components/TuningHistory/VerificationQualityWarning'; import './App.css'; +/** Pending verification data held while quality warning is shown */ +interface PendingVerification { + verificationMetrics?: FilterMetricsSummary; + verificationPidMetrics?: PIDMetricsSummary; + verificationTFMetrics?: TransferFunctionMetricsSummary; + dataQuality: DataQualityScore; + historyRecordId: string | null; + isReanalyze: boolean; +} + function AppContent() { const [showProfileWizard, setShowProfileWizard] = useState(false); const [newFCSerial, setNewFCSerial] = useState(null); @@ -73,6 +85,7 @@ function AppContent() { const [fixingSettings, setFixingSettings] = useState(false); const [showBannerFixConfirm, setShowBannerFixConfirm] = useState(false); const [analyzingVerification, setAnalyzingVerification] = useState(false); + const [pendingVerification, setPendingVerification] = useState(null); const [preparingSession, setPreparingSession] = useState(false); const [verificationPickerLogId, setVerificationPickerLogId] = useState(null); const [showLogPicker, setShowLogPicker] = useState(false); @@ -475,6 +488,62 @@ function AppContent() { } }; + /** Commit verification metrics to session/history (shared by direct path and quality-gate accept) */ + const commitVerification = async ( + verificationMetrics: FilterMetricsSummary | undefined, + verificationPidMetrics: PIDMetricsSummary | undefined, + verificationTFMetrics: TransferFunctionMetricsSummary | undefined, + historyRecordId: string | null, + isReanalyzeFlow: boolean + ) => { + if (historyRecordId) { + await window.betaflight.updateHistoryVerification( + historyRecordId, + verificationMetrics, + verificationPidMetrics + ); + await tuningHistory.reload(); + } else if (isReanalyzeFlow) { + await window.betaflight.updateVerificationMetrics( + verificationMetrics, + verificationTFMetrics, + verificationPidMetrics + ); + } else { + await tuning.updatePhase(TUNING_PHASE.COMPLETED, { + verificationMetrics, + verificationTransferFunctionMetrics: verificationTFMetrics, + verificationPidMetrics, + }); + } + setErasedForPhase(null); + }; + + const handleQualityGateAccept = async () => { + const pending = pendingVerification; + if (!pending) return; + setPendingVerification(null); // Clear immediately to prevent double-click + try { + setAnalyzingVerification(true); + await commitVerification( + pending.verificationMetrics, + pending.verificationPidMetrics, + pending.verificationTFMetrics, + pending.historyRecordId, + pending.isReanalyze + ); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to complete verification'); + } finally { + setAnalyzingVerification(false); + } + }; + + const handleQualityGateReject = () => { + setPendingVerification(null); + toast.info('Verification discarded. Fly again with more stick inputs for better data.'); + }; + const handleVerificationAnalyze = async (sessionIndex: number) => { const verLogId = verificationPickerLogId; const historyRecordId = reanalyzeHistoryRecordId; @@ -491,15 +560,18 @@ function AppContent() { let verificationMetrics: FilterMetricsSummary | undefined; let verificationPidMetrics: PIDMetricsSummary | undefined; let verificationTFMetrics: TransferFunctionMetricsSummary | undefined; + let dataQuality: DataQualityScore | undefined; if (isPidSession) { // PID Tune verification: run PID analysis (stick snaps comparison) const pidResult = await window.betaflight.analyzePID(verLogId, sessionIndex); verificationPidMetrics = extractPIDMetrics(pidResult); + dataQuality = pidResult.dataQuality; } else { // Filter Tune / Flash Tune: run filter analysis (noise/spectrogram comparison) const filterResult = await window.betaflight.analyzeFilters(verLogId, sessionIndex); verificationMetrics = extractFilterMetrics(filterResult); + dataQuality = filterResult.dataQuality; // Flash Tune: also run TF analysis on verification flight if (isFlashSession) { @@ -522,30 +594,31 @@ function AppContent() { } } - if (historyRecordId) { - // Re-analyze a historical record - await window.betaflight.updateHistoryVerification( - historyRecordId, - verificationMetrics, - verificationPidMetrics - ); - await tuningHistory.reload(); - } else if (isReanalyze) { - // Re-analyze — update session + history without duplicate archive - await window.betaflight.updateVerificationMetrics( - verificationMetrics, - verificationTFMetrics, - verificationPidMetrics - ); - } else { - // First-time — transition to completed (archives session) - await tuning.updatePhase(TUNING_PHASE.COMPLETED, { + // Quality gate: warn user if verification flight data quality is poor/fair + if ( + dataQuality && + (dataQuality.tier === 'poor' || dataQuality.tier === 'fair') && + !historyRecordId && + !isReanalyze + ) { + setPendingVerification({ verificationMetrics, - verificationTransferFunctionMetrics: verificationTFMetrics, verificationPidMetrics, + verificationTFMetrics, + dataQuality, + historyRecordId, + isReanalyze, }); + return; // Wait for user decision in VerificationQualityWarning modal } - setErasedForPhase(null); + + await commitVerification( + verificationMetrics, + verificationPidMetrics, + verificationTFMetrics, + historyRecordId, + isReanalyze + ); } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to analyze verification'); } finally { @@ -873,6 +946,14 @@ function AppContent() { /> )} + {pendingVerification && ( + + )} + {showTelemetrySettings && ( setShowTelemetrySettings(false)} /> )} diff --git a/src/renderer/components/TuningHistory/VerificationQualityWarning.test.tsx b/src/renderer/components/TuningHistory/VerificationQualityWarning.test.tsx new file mode 100644 index 00000000..521a6bea --- /dev/null +++ b/src/renderer/components/TuningHistory/VerificationQualityWarning.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { VerificationQualityWarning } from './VerificationQualityWarning'; +import type { DataQualityScore } from '@shared/types/analysis.types'; + +const fairQuality: DataQualityScore = { + overall: 46, + tier: 'fair', + subScores: [ + { name: 'Step count', score: 20, weight: 0.3 }, + { name: 'Axis coverage', score: 0, weight: 0.3 }, + { name: 'Magnitude variety', score: 100, weight: 0.2 }, + { name: 'Hold quality', score: 100, weight: 0.2 }, + ], +}; + +const poorQuality: DataQualityScore = { + overall: 15, + tier: 'poor', + subScores: [ + { name: 'Step count', score: 0, weight: 0.3 }, + { name: 'Axis coverage', score: 0, weight: 0.3 }, + { name: 'Magnitude variety', score: 50, weight: 0.2 }, + { name: 'Hold quality', score: 25, weight: 0.2 }, + ], +}; + +describe('VerificationQualityWarning', () => { + it('renders fair quality warning with score', () => { + render( + + ); + expect(screen.getByText(/Fair \(46\/100\)/)).toBeInTheDocument(); + expect(screen.getByText('Low Verification Data Quality')).toBeInTheDocument(); + }); + + it('renders poor quality warning', () => { + render( + + ); + expect(screen.getByText(/Poor \(15\/100\)/)).toBeInTheDocument(); + }); + + it('shows failing sub-scores', () => { + render( + + ); + expect(screen.getByText('Step count: 20/100')).toBeInTheDocument(); + expect(screen.getByText('Axis coverage: 0/100')).toBeInTheDocument(); + // Good sub-scores should not be shown + expect(screen.queryByText(/Magnitude variety/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Hold quality/)).not.toBeInTheDocument(); + }); + + it('calls onAccept when Accept Anyway clicked', async () => { + const onAccept = vi.fn(); + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByRole('button', { name: 'Accept Anyway' })); + expect(onAccept).toHaveBeenCalledOnce(); + }); + + it('calls onReject when Fly Again clicked', async () => { + const onReject = vi.fn(); + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByRole('button', { name: 'Fly Again' })); + expect(onReject).toHaveBeenCalledOnce(); + }); + + it('has correct dialog role', () => { + render( + + ); + expect( + screen.getByRole('dialog', { name: 'Verification quality warning' }) + ).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/TuningHistory/VerificationQualityWarning.tsx b/src/renderer/components/TuningHistory/VerificationQualityWarning.tsx new file mode 100644 index 00000000..237c4a68 --- /dev/null +++ b/src/renderer/components/TuningHistory/VerificationQualityWarning.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import type { DataQualityScore } from '@shared/types/analysis.types'; + +interface VerificationQualityWarningProps { + dataQuality: DataQualityScore; + onAccept: () => void; + onReject: () => void; +} + +const TIER_DISPLAY: Record = { + poor: { label: 'Poor', color: '#e74c3c' }, + fair: { label: 'Fair', color: '#f39c12' }, + good: { label: 'Good', color: '#27ae60' }, + excellent: { label: 'Excellent', color: '#2ecc71' }, +}; + +export function VerificationQualityWarning({ + dataQuality, + onAccept, + onReject, +}: VerificationQualityWarningProps) { + const { label: tierLabel, color: tierColor } = TIER_DISPLAY[dataQuality.tier]; + + return ( +
+
+

Low Verification Data Quality

+

+ The verification flight data quality is{' '} + + {tierLabel} ({dataQuality.overall}/100) + + . This may not give reliable results. +

+ + {dataQuality.subScores && dataQuality.subScores.length > 0 && ( +
+ {dataQuality.subScores + .filter((s) => s.score < 60) + .map((s) => ( +
+ {s.name}: {s.score}/100 +
+ ))} +
+ )} + +

+ For PID Tune, include at least 8-10 sharp stick snaps across roll and pitch axes. For + Filter Tune, include a steady throttle sweep from low to high. +

+ +
+ + +
+
+
+ ); +}