|
| 1 | +//! 前台自动暂停门控(批 2a:ForegroundGate 滞回状态机 + 锚定资格纯函数)。 |
| 2 | +//! |
| 3 | +//! @ai-context(Why):课堂跟随录制时用户切到其他窗口(IM/浏览器/编辑器)的时段 |
| 4 | +//! 是干扰期,内容不应混入笔记——媒体随播随停(REQ-291)只跟"视频 |
| 5 | +//! 是否暂停",跟不了"人是否在看屏幕"。前台门控提供第三个自动暂停 |
| 6 | +//! 源(PauseSource::Foreground):目标窗口失焦 连续 2×250ms foreign |
| 7 | +//! 确认 → 随停;目标回位 连续 2×250ms → 解除。与媒体源互不解除 |
| 8 | +//! (只解自己,机器层在 pause_state.rs)——本模块只产出决策, |
| 9 | +//! 动作由屏幕 worker 经 request API 发出。 |
| 10 | +//! @ai-context:滞回先例 fa1647aa(media_state SUSPEND 2 拍)——250ms 拍是前台 |
| 11 | +//! 查询的节流粒度(GetForegroundWindow 是进程级系统调用,比帧采样 |
| 12 | +//! 便宜一个量级;错 1 拍误停的代价高于晚 250ms 停);自窗(主窗/ |
| 13 | +//! 浮窗/overlay/原生对话框,windows::is_self_hwnd)=中性:不算 |
| 14 | +//! foreign(防自己的浮窗/设置触发随停)也不算 target(防误解除)。 |
| 15 | +//! @ai-context:无窗口锚定(全屏捕获)或画面链短路(disable_ocr,无 worker 采样 |
| 16 | +//! 点)时门控停摆(anchor_eligible=false——无目标可"失焦/回位")。 |
| 17 | +
|
| 18 | +/// 滞回确认所需连续拍数(250ms × 2 = 500ms;先例 fa1647aa SUSPECT 2 拍)。 |
| 19 | +/// 单拍过锐:临时切窗(复制课件/瞄一眼日历 <500ms)即误停不可接受。 |
| 20 | +pub const FG_CONFIRM_TICKS: u32 = 2; |
| 21 | +/// 前台采样节拍(ms):GetForegroundWindow 查询粒度下限(见模块头 Why)。 |
| 22 | +pub const FG_TICK_MS: u64 = 250; |
| 23 | + |
| 24 | +/// 前台观察分类(采样点合成:系统前台窗口 vs 目标窗口 vs 本进程自窗/探测失败)。 |
| 25 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 26 | +pub enum ForegroundObs { |
| 27 | + /// 系统前台 ≠ 目标窗口且非本进程窗口(离开学习内容) |
| 28 | + Foreign, |
| 29 | + /// 系统前台 = 目标窗口(回位) |
| 30 | + Target, |
| 31 | + /// 自窗或探测失败(中性——不推进也不撤销,见模块头) |
| 32 | + Neutral, |
| 33 | +} |
| 34 | + |
| 35 | +/// 门控决策(动作由 worker 经 request API 发出)。 |
| 36 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 37 | +pub enum ForegroundDecision { |
| 38 | + None, |
| 39 | + /// 确认离开 → 请求前台暂停 |
| 40 | + Suspend, |
| 41 | + /// 确认回位 → 解除前台暂停(只解自己) |
| 42 | + Resume, |
| 43 | +} |
| 44 | + |
| 45 | +/// 门控相位。 |
| 46 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 47 | +pub enum ForegroundPhase { |
| 48 | + /// 追踪中(目标在前台或中性) |
| 49 | + Active, |
| 50 | + /// 首个 foreign(等第二拍互证——防瞬间切窗误停) |
| 51 | + Suspect, |
| 52 | + /// 已确认离开(暂停生效中) |
| 53 | + Paused, |
| 54 | + /// 首个 target 回位(等第二拍互证——防瞬间切回误解除) |
| 55 | + Returning, |
| 56 | +} |
| 57 | + |
| 58 | +/// 前台滞回门控(屏幕 worker 单线程持有;输入=每 FG_TICK_MS 一次分类观察)。 |
| 59 | +/// |
| 60 | +/// @ai-context: Suspect 相位记录首个 foreign,随后 foreign 连续累计至 |
| 61 | +/// FG_CONFIRM_TICKS 才确认(中性不计数不撤销——见模块头"自窗= |
| 62 | +/// 中性"语义);Returning 对 target 对称。计数在相位迁出时清零。 |
| 63 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 64 | +pub struct ForegroundGate { |
| 65 | + pub phase: ForegroundPhase, |
| 66 | + /// Suspect 内已连续 foreign 拍数(中性拍不计) |
| 67 | + foreign_ticks: u32, |
| 68 | + /// Returning 内已连续 target 拍数(中性/foreign 拍中断回 Paused) |
| 69 | + target_ticks: u32, |
| 70 | +} |
| 71 | + |
| 72 | +impl ForegroundGate { |
| 73 | + pub fn new() -> Self { |
| 74 | + Self { phase: ForegroundPhase::Active, foreign_ticks: 0, target_ticks: 0 } |
| 75 | + } |
| 76 | + |
| 77 | + /// 喂一拍(滞回规则,SUSPEND/RESUME 对称确认): |
| 78 | + /// - Active:Foreign → Suspect(拍 1);Target/Neutral 停留 |
| 79 | + /// - Suspect:Foreign 累计至 FG_CONFIRM_TICKS → Paused(产出 Suspend); |
| 80 | + /// Target → 撤销回 Active;Neutral 不计不撤销(停留等互证) |
| 81 | + /// - Paused:Target → Returning(拍 1);Foreign/Neutral 停留(暂停继续) |
| 82 | + /// - Returning:Target 累计至 FG_CONFIRM_TICKS → Active(产出 Resume); |
| 83 | + /// Foreign/Neutral → 回 Paused(回位未获互证,保守留在暂停) |
| 84 | + pub fn tick(&mut self, obs: ForegroundObs) -> ForegroundDecision { |
| 85 | + match self.phase { |
| 86 | + ForegroundPhase::Active => match obs { |
| 87 | + ForegroundObs::Foreign => { |
| 88 | + self.phase = ForegroundPhase::Suspect; |
| 89 | + self.foreign_ticks = 1; |
| 90 | + ForegroundDecision::None |
| 91 | + } |
| 92 | + ForegroundObs::Target | ForegroundObs::Neutral => ForegroundDecision::None, |
| 93 | + }, |
| 94 | + ForegroundPhase::Suspect => match obs { |
| 95 | + ForegroundObs::Foreign => { |
| 96 | + self.foreign_ticks += 1; |
| 97 | + if self.foreign_ticks >= FG_CONFIRM_TICKS { |
| 98 | + self.foreign_ticks = 0; |
| 99 | + self.phase = ForegroundPhase::Paused; |
| 100 | + ForegroundDecision::Suspend |
| 101 | + } else { |
| 102 | + ForegroundDecision::None |
| 103 | + } |
| 104 | + } |
| 105 | + ForegroundObs::Target => { |
| 106 | + self.foreign_ticks = 0; |
| 107 | + self.phase = ForegroundPhase::Active; |
| 108 | + ForegroundDecision::None |
| 109 | + } |
| 110 | + ForegroundObs::Neutral => ForegroundDecision::None, |
| 111 | + }, |
| 112 | + ForegroundPhase::Paused => match obs { |
| 113 | + ForegroundObs::Target => { |
| 114 | + self.phase = ForegroundPhase::Returning; |
| 115 | + self.target_ticks = 1; |
| 116 | + ForegroundDecision::None |
| 117 | + } |
| 118 | + ForegroundObs::Foreign | ForegroundObs::Neutral => ForegroundDecision::None, |
| 119 | + }, |
| 120 | + ForegroundPhase::Returning => match obs { |
| 121 | + ForegroundObs::Target => { |
| 122 | + self.target_ticks += 1; |
| 123 | + if self.target_ticks >= FG_CONFIRM_TICKS { |
| 124 | + self.target_ticks = 0; |
| 125 | + self.phase = ForegroundPhase::Active; |
| 126 | + ForegroundDecision::Resume |
| 127 | + } else { |
| 128 | + ForegroundDecision::None |
| 129 | + } |
| 130 | + } |
| 131 | + ForegroundObs::Foreign | ForegroundObs::Neutral => { |
| 132 | + self.target_ticks = 0; |
| 133 | + self.phase = ForegroundPhase::Paused; |
| 134 | + ForegroundDecision::None |
| 135 | + } |
| 136 | + }, |
| 137 | + } |
| 138 | + } |
| 139 | +} |
| 140 | + |
| 141 | +impl Default for ForegroundGate { |
| 142 | + fn default() -> Self { |
| 143 | + Self::new() |
| 144 | + } |
| 145 | +} |
| 146 | + |
| 147 | +/// 锚定资格判定(纯函数):前台源只在"窗口锚定"时可用。 |
| 148 | +/// |
| 149 | +/// @ai-context: 目标窗口句柄缺失(None=全屏捕获——无"目标窗口"可失焦/回位, |
| 150 | +/// 前台恒为系统其他窗口,会立即误停);画面链短路(disable_ocr, |
| 151 | +/// worker 不存在——前台采样点缺失)。任一不满足 → 门控停摆。 |
| 152 | +pub fn anchor_eligible(has_target_hwnd: bool, ocr_enabled: bool) -> bool { |
| 153 | + has_target_hwnd && ocr_enabled |
| 154 | +} |
| 155 | + |
| 156 | +#[cfg(test)] |
| 157 | +mod tests { |
| 158 | + use super::*; |
| 159 | + |
| 160 | + #[test] |
| 161 | + fn two_consecutive_foreign_confirm_suspend() { |
| 162 | + // Arrange & Act:连续 2×250ms foreign → Suspend(拍 1 只进 Suspect) |
| 163 | + let mut g = ForegroundGate::new(); |
| 164 | + assert_eq!(g.tick(ForegroundObs::Foreign), ForegroundDecision::None); |
| 165 | + assert_eq!(g.phase, ForegroundPhase::Suspect); |
| 166 | + assert_eq!(g.tick(ForegroundObs::Foreign), ForegroundDecision::Suspend); |
| 167 | + assert_eq!(g.phase, ForegroundPhase::Paused); |
| 168 | + } |
| 169 | + |
| 170 | + #[test] |
| 171 | + fn single_foreign_blip_does_not_suspend() { |
| 172 | + // 瞬间切窗(<500ms)→ 目标回位 → 撤销怀疑,不误停 |
| 173 | + let mut g = ForegroundGate::new(); |
| 174 | + assert_eq!(g.tick(ForegroundObs::Foreign), ForegroundDecision::None); |
| 175 | + assert_eq!(g.tick(ForegroundObs::Target), ForegroundDecision::None); |
| 176 | + assert_eq!(g.phase, ForegroundPhase::Active); |
| 177 | + } |
| 178 | + |
| 179 | + #[test] |
| 180 | + fn self_window_is_neutral_neither_suspends_nor_cancels() { |
| 181 | + // 自窗(浮窗/设置)中性:不算 foreign(不推进确认)也不算 target(不撤销) |
| 182 | + let mut g = ForegroundGate::new(); |
| 183 | + assert_eq!(g.tick(ForegroundObs::Foreign), ForegroundDecision::None); |
| 184 | + assert_eq!(g.phase, ForegroundPhase::Suspect); |
| 185 | + assert_eq!(g.tick(ForegroundObs::Neutral), ForegroundDecision::None); |
| 186 | + assert_eq!(g.phase, ForegroundPhase::Suspect, "中性不撤销怀疑"); |
| 187 | + assert_eq!(g.tick(ForegroundObs::Foreign), ForegroundDecision::Suspend); |
| 188 | + assert_eq!(g.phase, ForegroundPhase::Paused); |
| 189 | + // 暂停期自窗中性:不解除 |
| 190 | + assert_eq!(g.tick(ForegroundObs::Neutral), ForegroundDecision::None); |
| 191 | + assert_eq!(g.phase, ForegroundPhase::Paused); |
| 192 | + } |
| 193 | + |
| 194 | + #[test] |
| 195 | + fn two_consecutive_targets_resume_after_pause() { |
| 196 | + // 回位解除同样滞回 2 拍;foreign/中性打断回位确认 → 保守留在暂停 |
| 197 | + let mut g = ForegroundGate::new(); |
| 198 | + assert_eq!(g.tick(ForegroundObs::Foreign), ForegroundDecision::None); |
| 199 | + assert_eq!(g.tick(ForegroundObs::Foreign), ForegroundDecision::Suspend); |
| 200 | + assert_eq!(g.tick(ForegroundObs::Target), ForegroundDecision::None); |
| 201 | + assert_eq!(g.phase, ForegroundPhase::Returning); |
| 202 | + // 拍 1 回位后短暂切走 → 回 Paused(回位未获互证) |
| 203 | + assert_eq!(g.tick(ForegroundObs::Foreign), ForegroundDecision::None); |
| 204 | + assert_eq!(g.phase, ForegroundPhase::Paused); |
| 205 | + // 再连续 2 拍 target → Resume |
| 206 | + assert_eq!(g.tick(ForegroundObs::Target), ForegroundDecision::None); |
| 207 | + assert_eq!(g.phase, ForegroundPhase::Returning); |
| 208 | + assert_eq!(g.tick(ForegroundObs::Target), ForegroundDecision::Resume); |
| 209 | + assert_eq!(g.phase, ForegroundPhase::Active); |
| 210 | + } |
| 211 | + |
| 212 | + #[test] |
| 213 | + fn anchor_eligibility_table() { |
| 214 | + // 判定表:全屏捕获无锚点 / 画面链短路 → 停摆 |
| 215 | + assert!(!anchor_eligible(false, true)); |
| 216 | + assert!(!anchor_eligible(true, false)); |
| 217 | + assert!(!anchor_eligible(false, false)); |
| 218 | + assert!(anchor_eligible(true, true)); |
| 219 | + } |
| 220 | + |
| 221 | + #[test] |
| 222 | + fn constants_documented_precedent() { |
| 223 | + // 节拍与阈值先例锚点(fa1647aa SUSPECT 2 拍)——防静默改值 |
| 224 | + assert_eq!(FG_CONFIRM_TICKS, 2); |
| 225 | + assert_eq!(FG_TICK_MS, 250); |
| 226 | + } |
| 227 | +} |
0 commit comments