diff --git a/app/src/main/java/com/papi/nova/api/PolarisApiClient.kt b/app/src/main/java/com/papi/nova/api/PolarisApiClient.kt index a3ba42e5..5f5391ad 100644 --- a/app/src/main/java/com/papi/nova/api/PolarisApiClient.kt +++ b/app/src/main/java/com/papi/nova/api/PolarisApiClient.kt @@ -849,8 +849,8 @@ class PolarisApiClient @JvmOverloads constructor( ?: health?.optString("primary_issue", "") ?: "" val likelyCause = explanation?.optString("likely_cause", "")?.takeIf { it.isNotBlank() } - ?: doctor?.optString("simple_state", "")?.takeIf { it.isNotBlank() } ?: doctor?.optString("summary", "")?.takeIf { it.isNotBlank() } + ?: doctor?.optString("simple_state", "")?.takeIf { it.isNotBlank() } ?: doctor?.optString("diagnosis", "")?.takeIf { it.isNotBlank() } ?: health?.optString("summary", "") ?: "" @@ -858,6 +858,9 @@ class PolarisApiClient @JvmOverloads constructor( ?: parseDoctorEvidence(doctor) val recommendation = doctor?.optJSONObject("recommendation") val safeAction = doctor?.optJSONObject("safe_recovery_action") + val actionPayload = safeAction?.optJSONObject("payload_preview") + val actionVerification = safeAction?.optJSONObject("verification") + val actionUndo = safeAction?.optJSONObject("undo") val tryFirst = parseStringArray(explanation?.optJSONArray("try_first")).takeIf { it.isNotEmpty() } ?: listOfNotNull( recommendation?.optString("body", "")?.takeIf { it.isNotBlank() }, @@ -866,10 +869,13 @@ class PolarisApiClient @JvmOverloads constructor( ).takeIf { it.isNotEmpty() } ?: parseStringArray(health?.optJSONArray("recommendations")) val confidence = explanation?.optString("confidence", "")?.takeIf { it.isNotBlank() } - ?: doctor?.optString("confidence", "")?.takeIf { it.isNotBlank() } + ?: doctor?.optJSONObject("confidence")?.optString("level", "")?.takeIf { it.isNotBlank() } + ?: doctor?.optString("confidence", "")?.takeIf { it.isNotBlank() && !it.startsWith("{") } ?: if (doctor != null) "deterministic" else if (primaryIssue.isNotBlank() || likelyCause.isNotBlank()) "fallback" else "" return PolarisSessionStatus.DoctorStatus( available = doctor != null, + version = doctor?.optInt("version", 0) ?: 0, + resultId = doctor?.optString("result_id", "") ?: "", classification = classifyDoctorIssue(primaryIssue), likelyCause = likelyCause, evidence = evidence, @@ -879,6 +885,14 @@ class PolarisApiClient @JvmOverloads constructor( ?: doctor?.optJSONObject("advanced_evidence")?.optString("summary", "") ?: "", primaryIssue = primaryIssue, + actionId = safeAction?.optString("id", "") ?: "", + actionLabel = safeAction?.optString("label", "") ?: "", + actionKind = safeAction?.optString("kind", "") ?: "", + targetBitrateKbps = actionPayload?.optInt("target_bitrate_kbps", 0) ?: 0, + verificationDelaySeconds = actionVerification?.optInt("delay_seconds", 0) ?: 0, + undoSupported = actionUndo?.optBoolean("supported", false) ?: false, + packetLossPct = parseDoctorEvidenceNumber(doctor, "packet_loss"), + latencyMs = parseDoctorEvidenceNumber(doctor, "latency"), destructiveActionAllowed = false ) } @@ -895,6 +909,16 @@ class PolarisApiClient @JvmOverloads constructor( } } + private fun parseDoctorEvidenceNumber(doctor: JSONObject?, id: String): Double? { + val array = doctor?.optJSONArray("evidence") ?: return null + for (index in 0 until array.length()) { + val item = array.optJSONObject(index) ?: continue + if (item.optString("id", "") != id || !item.has("value")) continue + return item.optDouble("value").takeIf { !it.isNaN() } + } + return null + } + private fun classifyDoctorIssue(issue: String): String { val normalized = issue.lowercase() return when { @@ -1835,6 +1859,56 @@ class PolarisApiClient @JvmOverloads constructor( } } + /** + * Execute an evidence-gated Doctor action on Polaris. + */ + fun runDoctorAction( + actionId: String, + sourceResultId: String = "", + targetBitrateKbps: Int = 0, + runId: String = "" + ): PolarisDoctorActionResult? { + return try { + val body = JSONObject().apply { + put("action_id", actionId) + if (sourceResultId.isNotBlank()) put("source_result_id", sourceResultId) + if (targetBitrateKbps > 0) put("target_bitrate_kbps", targetBitrateKbps) + if (runId.isNotBlank()) put("run_id", runId) + } + val request = Request.Builder() + .url("$baseUrl/doctor/action") + .post(okhttp3.RequestBody.create( + "application/json".toMediaTypeOrNull(), + body.toString() + )) + .build() + executeWithTransientRetry(request).use { response -> + if (response.code != 200) return null + val json = JSONObject(response.body?.string() ?: "{}") + val verification = json.optJSONObject("verification") + val undo = json.optJSONObject("undo") + val evidence = json.optJSONObject("evidence") + PolarisDoctorActionResult( + status = json.optBoolean("status", false), + changed = json.optBoolean("changed", false), + state = json.optString("state", ""), + message = json.optString("message", ""), + error = json.optString("error", ""), + runId = json.optString("run_id", ""), + verificationDelaySeconds = verification?.optInt("delay_seconds", 0) ?: 0, + verificationActionId = verification?.optString("action_id", "") ?: "", + undoAvailable = undo?.optBoolean("available", false) ?: false, + undoActionId = undo?.optString("action_id", "") ?: "", + evidencePacketLossPct = evidence?.optDouble("packet_loss_pct")?.takeIf { !it.isNaN() }, + evidenceLatencyMs = evidence?.optDouble("latency_ms")?.takeIf { !it.isNaN() } + ) + } + } catch (e: Exception) { + LimeLog.warning("Nova: Doctor action failed: ${errorMessage(e)}") + null + } + } + /** * Set the stream bitrate mid-session without reconnecting. */ diff --git a/app/src/main/java/com/papi/nova/api/PolarisSessionStatus.kt b/app/src/main/java/com/papi/nova/api/PolarisSessionStatus.kt index 8fc191b9..405683dd 100644 --- a/app/src/main/java/com/papi/nova/api/PolarisSessionStatus.kt +++ b/app/src/main/java/com/papi/nova/api/PolarisSessionStatus.kt @@ -254,6 +254,8 @@ data class PolarisSessionStatus( data class DoctorStatus( val available: Boolean = false, + val version: Int = 0, + val resultId: String = "", val classification: String = "UNKNOWN", val likelyCause: String = "", val evidence: List = emptyList(), @@ -261,9 +263,25 @@ data class PolarisSessionStatus( val confidence: String = "", val advancedDetail: String = "", val primaryIssue: String = "", + val actionId: String = "", + val actionLabel: String = "", + val actionKind: String = "", + val targetBitrateKbps: Int = 0, + val verificationDelaySeconds: Int = 0, + val undoSupported: Boolean = false, + val packetLossPct: Double? = null, + val latencyMs: Double? = null, val destructiveActionAllowed: Boolean = false ) { val firstTry get() = tryFirst.firstOrNull().orEmpty() + val networkPressureConfirmed get() = + (packetLossPct ?: 0.0) > 2.0 || (latencyMs ?: 0.0) >= 45.0 + val canExecuteAction get() = when (actionId) { + "recheck_network" -> version >= 2 + "lower_bitrate" -> version >= 2 && primaryIssue == "network_jitter" && networkPressureConfirmed + "restore_quality" -> version >= 2 && primaryIssue == "quality_capped_by_history" && targetBitrateKbps > 0 + else -> false + } } data class HealthStatus( @@ -413,3 +431,18 @@ data class PolarisSessionStatus( else -> "Stable" } } + +data class PolarisDoctorActionResult( + val status: Boolean, + val changed: Boolean = false, + val state: String = "", + val message: String = "", + val error: String = "", + val runId: String = "", + val verificationDelaySeconds: Int = 0, + val verificationActionId: String = "", + val undoAvailable: Boolean = false, + val undoActionId: String = "", + val evidencePacketLossPct: Double? = null, + val evidenceLatencyMs: Double? = null +) diff --git a/app/src/main/java/com/papi/nova/ui/NovaQuickMenu.kt b/app/src/main/java/com/papi/nova/ui/NovaQuickMenu.kt index 4036f7e1..21b0f361 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaQuickMenu.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaQuickMenu.kt @@ -22,6 +22,7 @@ import com.papi.nova.LimeLog import com.papi.nova.R import com.papi.nova.api.PolarisApiClient import com.papi.nova.api.PolarisCapabilities +import com.papi.nova.api.PolarisDoctorActionResult import com.papi.nova.api.PolarisSessionStatus import com.papi.nova.binding.input.GameInputDevice import com.papi.nova.binding.input.KeyboardTranslator @@ -90,6 +91,7 @@ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks { var advancedTuningVisible = false var profileClearInProgress = false var hostStateUnavailable = false + var doctorActionPending = false fun syncSessionDerivedState() { adaptiveEnabled = sessionStatus?.tuning?.adaptiveBitrateEnabled == true || @@ -180,6 +182,121 @@ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks { sendKeysWithFocus(quickKeys) } + fun doctorResultMessage(result: PolarisDoctorActionResult): String { + if (result.message.isNotBlank()) return result.message + return when (result.state) { + "stable" -> game.getString(R.string.nova_quick_menu_doctor_stable) + "confirmed_pressure" -> game.getString(R.string.nova_quick_menu_doctor_confirmed) + "watching" -> game.getString(R.string.nova_quick_menu_doctor_watching) + "resolved" -> game.getString(R.string.nova_quick_menu_doctor_resolved) + "needs_attention" -> game.getString(R.string.nova_quick_menu_doctor_needs_attention) + "undone" -> game.getString(R.string.nova_quick_menu_doctor_undone) + else -> result.error.takeIf { it.isNotBlank() } + ?: game.getString(R.string.nova_quick_menu_doctor_failed) + } + } + + fun undoDoctorRun(runId: String) { + if (apiClient == null || runId.isBlank() || doctorActionPending) return + doctorActionPending = true + game.launchRuntimeIo("NovaQuickMenuDoctorUndo") { + val result = apiClient.runDoctorAction(actionId = "undo", runId = runId) + if (result?.status == true) { + sessionStatus = apiClient.getSessionStatus() ?: sessionStatus + syncSessionDerivedState() + } + game.runOnMainIfRuntimeActive { + doctorActionPending = false + if (result?.status == true) { + NovaSnackbar.showSuccess(game, doctorResultMessage(result), anchor = composeView) + } else { + NovaSnackbar.showError( + game, + result?.error?.takeIf { it.isNotBlank() } + ?: game.getString(R.string.nova_quick_menu_doctor_failed), + anchor = composeView + ) + } + refreshState() + } + } + } + + fun presentDoctorResult(result: PolarisDoctorActionResult) { + val message = doctorResultMessage(result) + if (!result.status) { + NovaSnackbar.showError(game, message, anchor = composeView) + return + } + if (result.undoAvailable && result.runId.isNotBlank()) { + NovaSnackbar.showSuccessWithAction( + activity = game, + message = message, + actionLabel = game.getString(R.string.nova_quick_menu_doctor_undo), + anchor = composeView, + onAction = { undoDoctorRun(result.runId) } + ) + } else { + NovaSnackbar.showSuccess(game, message, anchor = composeView) + } + } + + fun scheduleDoctorVerification(result: PolarisDoctorActionResult) { + if (!result.status || result.runId.isBlank() || result.verificationActionId.isBlank()) return + val delayMs = (result.verificationDelaySeconds.coerceAtLeast(1) * 1000L) + game.window.decorView.postDelayed({ + if (apiClient == null) return@postDelayed + game.launchRuntimeIo("NovaQuickMenuDoctorVerify") { + val verification = apiClient.runDoctorAction( + actionId = result.verificationActionId, + runId = result.runId + ) + if (verification?.status == true) { + sessionStatus = apiClient.getSessionStatus() ?: sessionStatus + syncSessionDerivedState() + } + game.runOnMainIfRuntimeActive { + verification?.let { + presentDoctorResult(it) + scheduleDoctorVerification(it) + } + refreshState() + } + } + }, delayMs) + } + + fun runDoctorAction() { + val status = sessionStatus + val doctor = status?.doctor + if (apiClient == null || status == null || doctor == null || !doctor.canExecuteAction || doctorActionPending) { + game.copyNovaHudDiagnostics() + return + } + doctorActionPending = true + game.launchRuntimeIo("NovaQuickMenuDoctorAction") { + val result = apiClient.runDoctorAction( + actionId = doctor.actionId, + sourceResultId = doctor.resultId, + targetBitrateKbps = doctor.targetBitrateKbps + ) + if (result?.status == true) { + sessionStatus = apiClient.getSessionStatus() ?: sessionStatus + syncSessionDerivedState() + } + game.runOnMainIfRuntimeActive { + doctorActionPending = false + if (result == null) { + NovaSnackbar.showError(game, game.getString(R.string.nova_quick_menu_doctor_failed), anchor = composeView) + } else { + presentDoctorResult(result) + scheduleDoctorVerification(result) + } + refreshState() + } + } + } + val callbacks = NovaQuickMenuCallbacks( onDismiss = { dismiss() }, onDisconnect = { @@ -405,7 +522,9 @@ class NovaQuickMenu(private val game: Game) : Game.GameMenuCallbacks { } game.toggleHUD() } - NovaQuickMenuActionId.DIAGNOSE_STREAM, + NovaQuickMenuActionId.DIAGNOSE_STREAM -> { + runDoctorAction() + } NovaQuickMenuActionId.COPY_HUD_DIAGNOSTICS -> { game.copyNovaHudDiagnostics() } diff --git a/app/src/main/java/com/papi/nova/ui/NovaQuickMenuContent.kt b/app/src/main/java/com/papi/nova/ui/NovaQuickMenuContent.kt index 543a118d..ef6c9c0e 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaQuickMenuContent.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaQuickMenuContent.kt @@ -484,10 +484,14 @@ private fun NovaQuickMenuDiagnosisCard( }, action = NovaQuickMenuAction( id = NovaQuickMenuActionId.DIAGNOSE_STREAM, - label = "${diagnosis.classification.takeIf { it in setOf("HOST", "NET", "CLIENT") } ?: "DIAG"}: ${diagnosis.likelyCause}", - caption = detail.ifBlank { "HOST / NET / CLIENT self-service diagnostics" }, + label = diagnosis.actionLabel.takeIf { diagnosis.actionExecutable && it.isNotBlank() } + ?: "${diagnosis.classification.takeIf { it in setOf("HOST", "NET", "CLIENT") } ?: "DIAG"}: ${diagnosis.likelyCause}", + caption = buildList { + diagnosis.likelyCause.takeIf { diagnosis.actionExecutable && it.isNotBlank() }?.let { add(it) } + detail.takeIf { it.isNotBlank() }?.let { add(it) } + }.joinToString(" ยท ").ifBlank { "HOST / NET / CLIENT self-service diagnostics" }, chip = NovaQuickMenuChip( - label = if (diagnosis.available) "Doctor" else "Fallback", + label = if (diagnosis.actionExecutable) "One click" else if (diagnosis.available) "Doctor" else "Fallback", tone = if (diagnosis.available) NovaQuickMenuTone.INFO else NovaQuickMenuTone.MUTED ), enabled = diagnosis.available diff --git a/app/src/main/java/com/papi/nova/ui/NovaQuickMenuUiState.kt b/app/src/main/java/com/papi/nova/ui/NovaQuickMenuUiState.kt index 854ff987..7016f605 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaQuickMenuUiState.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaQuickMenuUiState.kt @@ -91,7 +91,13 @@ data class NovaQuickMenuDiagnosisState( val evidence: List, val tryFirst: String, val confidence: String, - val available: Boolean + val available: Boolean, + val actionId: String, + val actionLabel: String, + val actionExecutable: Boolean, + val targetBitrateKbps: Int, + val verificationDelaySeconds: Int, + val undoSupported: Boolean ) data class NovaQuickMenuUiState( @@ -500,7 +506,13 @@ data class NovaQuickMenuUiState( evidence = doctor?.evidence ?: emptyList(), tryFirst = doctor?.firstTry.orEmpty(), confidence = doctor?.confidence.orEmpty(), - available = status != null && (doctor?.likelyCause?.isNotBlank() == true || doctor?.primaryIssue?.isNotBlank() == true) + available = status != null && (doctor?.likelyCause?.isNotBlank() == true || doctor?.primaryIssue?.isNotBlank() == true), + actionId = doctor?.actionId.orEmpty(), + actionLabel = doctor?.actionLabel.orEmpty(), + actionExecutable = doctor?.canExecuteAction == true && status?.canAdjustHostTuning == true, + targetBitrateKbps = doctor?.targetBitrateKbps ?: 0, + verificationDelaySeconds = doctor?.verificationDelaySeconds ?: 0, + undoSupported = doctor?.undoSupported == true ) } @@ -516,7 +528,8 @@ data class NovaQuickMenuUiState( } return NovaQuickMenuAction( id = NovaQuickMenuActionId.DIAGNOSE_STREAM, - label = context.getString(R.string.nova_quick_menu_diagnose_stream), + label = diagnosis.actionLabel.takeIf { diagnosis.actionExecutable && it.isNotBlank() } + ?: context.getString(R.string.nova_quick_menu_diagnose_stream), caption = diagnosis.likelyCause, chip = chip(classification, tone), enabled = status != null diff --git a/app/src/main/java/com/papi/nova/ui/NovaSnackbar.kt b/app/src/main/java/com/papi/nova/ui/NovaSnackbar.kt index 1c66c2c0..91fca2ac 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaSnackbar.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaSnackbar.kt @@ -69,13 +69,34 @@ object NovaSnackbar { ) } + fun showSuccessWithAction( + activity: Activity, + message: String, + actionLabel: String, + anchor: View? = null, + onAction: () -> Unit + ) { + showStyled( + activity = activity, + message = message, + duration = Snackbar.LENGTH_LONG, + textColor = activity.getColor(R.color.nova_success), + surfaceAlpha = SurfaceAlpha, + anchor = anchor, + actionLabel = actionLabel, + onAction = onAction + ) + } + private fun showStyled( activity: Activity, message: String, duration: Int, textColor: Int, surfaceAlpha: Int, - anchor: View? = null + anchor: View? = null, + actionLabel: String? = null, + onAction: (() -> Unit)? = null ) { // A Snackbar is drawn inside the window of the view it is given. The activity's // content view is the wrong window whenever a Dialog is up -- the in-stream Command @@ -98,6 +119,9 @@ object NovaSnackbar { ) snackbar.setTextColor(textColor) snackbar.setActionTextColor(NovaThemeManager.getAccentColor(activity)) + if (!actionLabel.isNullOrBlank() && onAction != null) { + snackbar.setAction(actionLabel) { onAction() } + } snackbar.view.alpha = if (surfaceAlpha < SurfaceAlpha) 0.88f else 0.94f snackbar.addCallback(object : Snackbar.Callback() { override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 96bc2c05..ab6e7097 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -962,6 +962,14 @@ Not selected Stats Overlay Diagnose This Stream + Doctor verified the network is stable. Bitrate was left unchanged. + Doctor now has enough live evidence. Press Fix and verify to apply one safe step. + Fix applied. Doctor is watching live loss and latency. + Doctor verified that network pressure cleared. + One safe step did not clear the issue. Doctor stopped before changing anything else. + Doctor could not apply this action. + Undo + Doctor restored the previous bitrate and Auto Quality state. Copy HUD Diagnostics Privacy-safe stream summary for bug reports. Nova HUD diagnostics copied diff --git a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt index 7b882eb3..b5144d31 100644 --- a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt +++ b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt @@ -308,9 +308,14 @@ class PolarisApiClientParsingTest { fun parseSessionStatusResponse_includesPolarisDoctorDiagnosis() { val json = JSONObject( "{\"state\":\"streaming\",\"streaming_active\":true," + - "\"doctor\":{\"simple_state\":\"Network issue detected\",\"primary_issue\":\"network_jitter\"," + - "\"evidence\":[{\"detail\":\"Packet loss is 3.4% over the last sample window.\"}]," + - "\"recommendation\":{\"body\":\"Lower bitrate one step or keep Adaptive Bitrate enabled.\",\"next_step_label\":\"Lower bitrate\"}}," + + "\"doctor\":{\"version\":2,\"result_id\":\"doctor-v2-needs_action-network_jitter-gpu_native\"," + + "\"simple_state\":\"Network issue detected\",\"primary_issue\":\"network_jitter\"," + + "\"confidence\":{\"level\":\"high\"}," + + "\"evidence\":[{\"id\":\"packet_loss\",\"value\":3.4,\"detail\":\"Packet loss is 3.4% over the last sample window.\"}," + + "{\"id\":\"latency\",\"value\":12.0,\"detail\":\"Latency is 12ms.\"}]," + + "\"recommendation\":{\"body\":\"Current evidence confirms network pressure.\",\"next_step_label\":\"Fix and verify\"}," + + "\"safe_recovery_action\":{\"id\":\"lower_bitrate\",\"label\":\"Fix and verify\",\"kind\":\"live_tuning\"," + + "\"payload_preview\":{\"target_bitrate_kbps\":16000},\"verification\":{\"delay_seconds\":8},\"undo\":{\"supported\":true}}}," + "\"ai_doctor_explanation\":{\"status\":true,\"explanation\":{\"likely_cause\":\"Wi-Fi jitter is the likely bottleneck.\"," + "\"evidence\":[\"3.4% packet loss\"],\"try_first\":[\"Lower bitrate\"]," + "\"advanced_detail\":\"Network evidence beats encoder speculation.\",\"confidence\":\"high\",\"destructive_action_allowed\":true}}}" @@ -324,6 +329,14 @@ class PolarisApiClientParsingTest { assertEquals("3.4% packet loss", status.doctor.evidence.first()) assertEquals("Lower bitrate", status.doctor.tryFirst.first()) assertEquals("high", status.doctor.confidence) + assertEquals(2, status.doctor.version) + assertEquals("lower_bitrate", status.doctor.actionId) + assertEquals("Fix and verify", status.doctor.actionLabel) + assertEquals(16000, status.doctor.targetBitrateKbps) + assertEquals(8, status.doctor.verificationDelaySeconds) + assertTrue(status.doctor.undoSupported) + assertEquals(3.4, status.doctor.packetLossPct!!, 0.01) + assertTrue(status.doctor.canExecuteAction) assertFalse(status.doctor.destructiveActionAllowed) } diff --git a/app/src/test/java/com/papi/nova/ui/NovaInStreamFeedbackTest.kt b/app/src/test/java/com/papi/nova/ui/NovaInStreamFeedbackTest.kt index c5a0ede3..16afaf1f 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaInStreamFeedbackTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaInStreamFeedbackTest.kt @@ -51,7 +51,7 @@ class NovaInStreamFeedbackTest { fun theCommandCenterAnchorsToItsOwnDrawer() { val menu = File("src/main/java/com/papi/nova/ui/NovaQuickMenu.kt").readText() - val calls = Regex("""NovaSnackbar\.(show|showError|showSuccess|showQuiet)\(""") + val calls = Regex("""NovaSnackbar\.(show|showError|showSuccess|showSuccessWithAction|showQuiet)\(""") .findAll(menu).count() val anchored = Regex("""anchor = composeView""").findAll(menu).count() diff --git a/app/src/test/java/com/papi/nova/ui/NovaQuickMenuUiStateTest.kt b/app/src/test/java/com/papi/nova/ui/NovaQuickMenuUiStateTest.kt index 3e2f930f..7ce87401 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaQuickMenuUiStateTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaQuickMenuUiStateTest.kt @@ -232,24 +232,37 @@ class NovaQuickMenuUiStateTest { status = status( doctor = PolarisSessionStatus.DoctorStatus( available = true, + version = 2, + resultId = "doctor-v2-needs_action-network_jitter-gpu_native", classification = "NET", likelyCause = "Wi-Fi jitter is the likely bottleneck.", evidence = listOf("3.4% packet loss"), tryFirst = listOf("Lower bitrate"), - confidence = "high" + confidence = "high", + primaryIssue = "network_jitter", + actionId = "lower_bitrate", + actionLabel = "Fix and verify", + actionKind = "live_tuning", + targetBitrateKbps = 16000, + verificationDelaySeconds = 8, + undoSupported = true, + packetLossPct = 3.4, + latencyMs = 12.0 ) ) ) val diagnose = state.overlayRows.first() assertEquals(NovaQuickMenuActionId.DIAGNOSE_STREAM, diagnose.id) - assertEquals("Diagnose This Stream", diagnose.label) + assertEquals("Fix and verify", diagnose.label) assertEquals("Wi-Fi jitter is the likely bottleneck.", diagnose.caption) assertEquals("NET", diagnose.chip!!.label) assertEquals(NovaQuickMenuTone.WARNING, diagnose.chip.tone) assertEquals("Lower bitrate", state.diagnosis.tryFirst) assertEquals("3.4% packet loss", state.diagnosis.evidence.first()) assertEquals("high", state.diagnosis.confidence) + assertTrue(state.diagnosis.actionExecutable) + assertEquals(16000, state.diagnosis.targetBitrateKbps) } @Test @@ -262,6 +275,83 @@ class NovaQuickMenuUiStateTest { assertEquals("Connect to Polaris for HOST / NET / CLIENT diagnostics.", diagnose.caption) } + @Test + fun networkObservationOnlyOffersAReadOnlyRecheck() { + val state = quickState( + status = status( + doctor = PolarisSessionStatus.DoctorStatus( + available = true, + version = 2, + classification = "NET", + likelyCause = "A network warning needs more live evidence before Doctor changes quality.", + primaryIssue = "network_observation", + actionId = "recheck_network", + actionLabel = "Recheck network", + actionKind = "verification", + packetLossPct = 0.4, + latencyMs = 20.0 + ) + ) + ) + + assertEquals("Recheck network", state.overlayRows.first().label) + assertTrue(state.diagnosis.actionExecutable) + assertEquals(0, state.diagnosis.targetBitrateKbps) + } + + @Test + fun staleNetworkLabelCannotExecuteBitrateReductionWithoutLiveEvidence() { + val state = quickState( + status = status( + doctor = PolarisSessionStatus.DoctorStatus( + available = true, + version = 2, + classification = "NET", + likelyCause = "Old network warning", + primaryIssue = "network_jitter", + actionId = "lower_bitrate", + actionLabel = "Fix and verify", + actionKind = "live_tuning", + targetBitrateKbps = 7580, + packetLossPct = 0.0, + latencyMs = 3.8 + ) + ) + ) + + assertFalse(state.diagnosis.actionExecutable) + assertEquals("Diagnose This Stream", state.overlayRows.first().label) + } + + @Test + fun cleanHistorySafeCapOffersOneClickQualityRestore() { + val state = quickState( + status = status( + doctor = PolarisSessionStatus.DoctorStatus( + available = true, + version = 2, + resultId = "doctor-v2-needs_action-quality_capped_by_history-gpu_native", + classification = "HOST", + likelyCause = "An older recovery profile is limiting quality even though the live network is stable.", + primaryIssue = "quality_capped_by_history", + actionId = "restore_quality", + actionLabel = "Restore and verify", + actionKind = "live_tuning", + targetBitrateKbps = 20000, + verificationDelaySeconds = 8, + undoSupported = true, + packetLossPct = 0.0, + latencyMs = 3.8 + ) + ) + ) + + assertEquals("Restore and verify", state.overlayRows.first().label) + assertTrue(state.diagnosis.actionExecutable) + assertEquals(20000, state.diagnosis.targetBitrateKbps) + assertTrue(state.diagnosis.undoSupported) + } + @Test fun overlayRowsExposePrivacySafeHudDiagnosticCopy() { val state = quickState(status = status(), currentGameName = "Portal")