Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 101 additions & 20 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
Expand All @@ -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<PendingVerification | null>(null);
const [preparingSession, setPreparingSession] = useState(false);
const [verificationPickerLogId, setVerificationPickerLogId] = useState<string | null>(null);
const [showLogPicker, setShowLogPicker] = useState(false);
Expand Down Expand Up @@ -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);
}
Comment on lines +522 to +539
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleQualityGateAccept can be triggered multiple times (e.g., double-clicking “Accept Anyway”), which could run commitVerification concurrently and perform duplicate updates. Consider guarding against re-entry (disable modal buttons while accepting, clear pendingVerification before awaiting, or add an in-flight flag).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — pendingVerification is cleared before await in 22f0dcf.

};

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;
Expand All @@ -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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -873,6 +946,14 @@ function AppContent() {
/>
)}

{pendingVerification && (
<VerificationQualityWarning
dataQuality={pendingVerification.dataQuality}
onAccept={handleQualityGateAccept}
onReject={handleQualityGateReject}
/>
)}

{showTelemetrySettings && (
<TelemetrySettingsModal onClose={() => setShowTelemetrySettings(false)} />
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<VerificationQualityWarning dataQuality={fairQuality} onAccept={vi.fn()} onReject={vi.fn()} />
);
expect(screen.getByText(/Fair \(46\/100\)/)).toBeInTheDocument();
expect(screen.getByText('Low Verification Data Quality')).toBeInTheDocument();
});

it('renders poor quality warning', () => {
render(
<VerificationQualityWarning dataQuality={poorQuality} onAccept={vi.fn()} onReject={vi.fn()} />
);
expect(screen.getByText(/Poor \(15\/100\)/)).toBeInTheDocument();
});

it('shows failing sub-scores', () => {
render(
<VerificationQualityWarning dataQuality={fairQuality} onAccept={vi.fn()} onReject={vi.fn()} />
);
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(
<VerificationQualityWarning
dataQuality={fairQuality}
onAccept={onAccept}
onReject={vi.fn()}
/>
);
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(
<VerificationQualityWarning
dataQuality={fairQuality}
onAccept={vi.fn()}
onReject={onReject}
/>
);
await user.click(screen.getByRole('button', { name: 'Fly Again' }));
expect(onReject).toHaveBeenCalledOnce();
});

it('has correct dialog role', () => {
render(
<VerificationQualityWarning dataQuality={fairQuality} onAccept={vi.fn()} onReject={vi.fn()} />
);
expect(
screen.getByRole('dialog', { name: 'Verification quality warning' })
).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -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<DataQualityScore['tier'], { label: string; color: string }> = {
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 (
<div className="profile-wizard-overlay" role="dialog" aria-label="Verification quality warning">
<div className="profile-wizard-modal" style={{ maxWidth: 480 }}>
<h3>Low Verification Data Quality</h3>
<p style={{ margin: '12px 0' }}>
The verification flight data quality is{' '}
<strong style={{ color: tierColor }}>
{tierLabel} ({dataQuality.overall}/100)
</strong>
. This may not give reliable results.
</p>

{dataQuality.subScores && dataQuality.subScores.length > 0 && (
<div style={{ margin: '12px 0', fontSize: 13, color: 'var(--text-secondary, #888)' }}>
{dataQuality.subScores
.filter((s) => s.score < 60)
.map((s) => (
<div key={s.name}>
{s.name}: {s.score}/100
</div>
))}
</div>
)}

<p style={{ margin: '12px 0', fontSize: 13 }}>
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.
</p>

<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 20 }}>
<button className="wizard-btn wizard-btn-secondary" onClick={onReject}>
Fly Again
</button>
<button className="wizard-btn wizard-btn-primary" onClick={onAccept}>
Accept Anyway
</button>
</div>
</div>
</div>
);
}
Loading