use std::error::Error;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::bridge::VisionBridge;
use crate::burst::{self, BurstConfig, BurstFormat};
use crate::input::{self, InputState};
pub struct GateConfig {
pub burst: BurstConfig,
pub tile_size: u32,
pub expectations: Option<GateExpectations>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GateExpectations {
pub max_changed_tiles: Option<u32>,
pub mouse_region: Option<Rect>,
pub gamepad_required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rect {
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
}
impl Rect {
pub fn contains(&self, px: i32, py: i32) -> bool {
px >= self.x
&& px < self.x + self.width
&& py >= self.y
&& py < self.y + self.height
}
}
#[derive(Debug, Serialize)]
pub struct GateReport {
pub status: String,
pub timestamp: String,
pub screenshot_path: String,
pub capture_backend: String,
pub resolution: (u32, u32),
pub compressed_frame: CompressedFrameJson,
pub input_state: InputState,
pub assessment: Assessment,
pub burst_frames: Option<Vec<CompressedFrameJson>>,
pub gif_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompressedFrameJson {
pub total_tiles: u32,
pub changed_tiles: u32,
pub change_ratio: f32,
pub tiles: Vec<TileJson>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TileJson {
pub col: u16,
pub row: u16,
pub mean_color: [u8; 4],
pub edge_density: f32,
pub likely_text: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Assessment {
pub pass: bool,
pub reasons: Vec<String>,
}
pub const VIRTUAL_WIDTH: i32 = 640;
pub const VIRTUAL_HEIGHT: i32 = 480;
pub fn map_to_virtual(
mouse_x: i32,
mouse_y: i32,
window_width: i32,
window_height: i32,
) -> (i32, i32) {
if window_width <= 0 || window_height <= 0 {
return (0, 0);
}
let vx = mouse_x * VIRTUAL_WIDTH / window_width;
let vy = mouse_y * VIRTUAL_HEIGHT / window_height;
(vx, vy)
}
pub fn assess(
compressed: &CompressedFrameJson,
input_state: &InputState,
expectations: &Option<GateExpectations>,
) -> Assessment {
match expectations {
None => {
let mut reasons = Vec::new();
reasons.push(format!(
"{} changed tiles out of {} total (ratio: {:.4})",
compressed.changed_tiles, compressed.total_tiles, compressed.change_ratio
));
reasons.push(format!(
"mouse at ({}, {})",
input_state.mouse.x, input_state.mouse.y
));
if let Some(ref gp) = input_state.gamepad {
reasons.push(format!("gamepad connected (buttons: {})", gp.buttons));
} else {
reasons.push("no gamepad connected".to_string());
}
Assessment {
pass: true,
reasons,
}
}
Some(exp) => {
let mut pass = true;
let mut reasons = Vec::new();
if let Some(threshold) = exp.max_changed_tiles {
let tile_pass = compressed.changed_tiles <= threshold;
if !tile_pass {
pass = false;
reasons.push(format!(
"FAIL: {} changed tiles exceeds threshold {}",
compressed.changed_tiles, threshold
));
} else {
reasons.push(format!(
"{} changed tiles within threshold {}",
compressed.changed_tiles, threshold
));
}
}
if let Some(ref region) = exp.mouse_region {
let mouse_in = region.contains(input_state.mouse.x, input_state.mouse.y);
if !mouse_in {
pass = false;
reasons.push(format!(
"FAIL: mouse ({}, {}) outside region ({}, {}, {}x{})",
input_state.mouse.x,
input_state.mouse.y,
region.x,
region.y,
region.width,
region.height
));
} else {
reasons.push("mouse in expected region".to_string());
}
}
if exp.gamepad_required {
let gp_ok = input_state.gamepad.is_some();
if !gp_ok {
pass = false;
reasons.push("FAIL: gamepad required but not connected".to_string());
} else {
reasons.push("gamepad connected".to_string());
}
}
Assessment { pass, reasons }
}
}
}
fn compressed_to_json(cf: &crate::inlined_types::CompressedFrame) -> CompressedFrameJson {
let total = cf.total_tiles;
let changed = cf.changed_tiles;
let change_ratio = if total > 0 {
changed as f32 / total as f32
} else {
0.0
};
let tiles = cf
.tiles
.iter()
.map(|t: &crate::inlined_types::TileDescriptor| TileJson {
col: t.col,
row: t.row,
mean_color: t.mean_color,
edge_density: t.edge_density,
likely_text: t.likely_text,
})
.collect();
CompressedFrameJson {
total_tiles: total,
changed_tiles: changed,
change_ratio,
tiles,
}
}
pub fn run_gate(config: GateConfig) -> Result<GateReport, Box<dyn Error>> {
run_gate_with_bridge(config, &mut None)
}
pub fn run_gate_with_bridge(
config: GateConfig,
bridge_state: &mut Option<VisionBridge>,
) -> Result<GateReport, Box<dyn Error>> {
let burst_result = burst::capture_burst(&config.burst)?;
if burst_result.frame_paths.is_empty() {
return Err("gate: no frames captured".into());
}
let bridge = bridge_state.get_or_insert_with(|| VisionBridge::new(config.tile_size));
let mut compressed_frames: Vec<CompressedFrameJson> = Vec::new();
for frame_path in &burst_result.frame_paths {
let cf = bridge.compress_bmp(frame_path)?;
compressed_frames.push(compressed_to_json(&cf));
}
let primary_frame = compressed_frames.last().cloned().unwrap();
let input_state = input::read_input_state();
let screenshot_path = burst_result
.frame_paths
.last()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if !screenshot_path.is_empty() && !Path::new(&screenshot_path).exists() {
return Err(format!("gate: screenshot_path does not exist: {}", screenshot_path).into());
}
let capture_backend = "dxgi".to_string();
let resolution = (VIRTUAL_WIDTH as u32, VIRTUAL_HEIGHT as u32);
let assessment = assess(&primary_frame, &input_state, &config.expectations);
let (burst_frames, gif_path) = if burst_result.frame_count > 1 {
let gif = if config.burst.output_format == BurstFormat::Gif {
let gif_candidate = burst_result.output_path.to_string_lossy().to_string();
if gif_candidate.ends_with(".gif") && Path::new(&gif_candidate).exists() {
Some(gif_candidate)
} else {
None
}
} else {
None
};
(Some(compressed_frames), gif)
} else {
(None, None)
};
Ok(GateReport {
status: "ok".to_string(),
timestamp: burst_result.timestamp,
screenshot_path,
capture_backend,
resolution,
compressed_frame: primary_frame,
input_state,
assessment,
burst_frames,
gif_path,
})
}
pub fn build_gate_report(
compressed_frame: CompressedFrameJson,
input_state: InputState,
expectations: &Option<GateExpectations>,
burst_frames: Option<Vec<CompressedFrameJson>>,
gif_path: Option<String>,
) -> GateReport {
let assessment = assess(&compressed_frame, &input_state, expectations);
GateReport {
status: "ok".to_string(),
timestamp: "20260426_143022".to_string(),
screenshot_path: "test_screenshot.bmp".to_string(),
capture_backend: "test".to_string(),
resolution: (VIRTUAL_WIDTH as u32, VIRTUAL_HEIGHT as u32),
compressed_frame,
input_state,
assessment,
burst_frames,
gif_path,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::{GamepadState, MouseState};
use proptest::prelude::*;
fn make_compressed(total_tiles: u32, changed_tiles: u32) -> CompressedFrameJson {
let change_ratio = if total_tiles > 0 {
changed_tiles as f32 / total_tiles as f32
} else {
0.0
};
let tiles: Vec<TileJson> = (0..changed_tiles)
.map(|i| TileJson {
col: i as u16,
row: 0,
mean_color: [128, 128, 128, 255],
edge_density: 0.1,
likely_text: true,
})
.collect();
CompressedFrameJson {
total_tiles,
changed_tiles,
change_ratio,
tiles,
}
}
fn make_input(mx: i32, my: i32, has_gamepad: bool) -> InputState {
InputState {
mouse: MouseState {
x: mx,
y: my,
screen_width: 1920,
screen_height: 1080,
},
gamepad: if has_gamepad {
Some(GamepadState {
connected: true,
left_stick: (0.0, 0.0),
right_stick: (0.0, 0.0),
left_trigger: 0.0,
right_trigger: 0.0,
buttons: 0,
})
} else {
None
},
}
}
#[test]
fn rect_contains_inside() {
let r = Rect { x: 10, y: 20, width: 100, height: 50 };
assert!(r.contains(10, 20));
assert!(r.contains(50, 40));
assert!(r.contains(109, 69));
}
#[test]
fn rect_contains_outside() {
let r = Rect { x: 10, y: 20, width: 100, height: 50 };
assert!(!r.contains(9, 20));
assert!(!r.contains(10, 19));
assert!(!r.contains(110, 20));
assert!(!r.contains(10, 70));
}
#[test]
fn assess_no_expectations_is_informational() {
let cf = make_compressed(100, 5);
let input = make_input(100, 200, false);
let a = assess(&cf, &input, &None);
assert!(a.pass);
assert!(!a.reasons.is_empty());
}
#[test]
fn assess_threshold_pass() {
let cf = make_compressed(100, 5);
let input = make_input(100, 200, false);
let exp = Some(GateExpectations {
max_changed_tiles: Some(10),
mouse_region: None,
gamepad_required: false,
});
let a = assess(&cf, &input, &exp);
assert!(a.pass);
}
#[test]
fn assess_threshold_fail() {
let cf = make_compressed(100, 15);
let input = make_input(100, 200, false);
let exp = Some(GateExpectations {
max_changed_tiles: Some(10),
mouse_region: None,
gamepad_required: false,
});
let a = assess(&cf, &input, &exp);
assert!(!a.pass);
}
#[test]
fn assess_mouse_region_pass() {
let cf = make_compressed(100, 0);
let input = make_input(50, 50, false);
let exp = Some(GateExpectations {
max_changed_tiles: None,
mouse_region: Some(Rect { x: 0, y: 0, width: 100, height: 100 }),
gamepad_required: false,
});
let a = assess(&cf, &input, &exp);
assert!(a.pass);
}
#[test]
fn assess_mouse_region_fail() {
let cf = make_compressed(100, 0);
let input = make_input(150, 50, false);
let exp = Some(GateExpectations {
max_changed_tiles: None,
mouse_region: Some(Rect { x: 0, y: 0, width: 100, height: 100 }),
gamepad_required: false,
});
let a = assess(&cf, &input, &exp);
assert!(!a.pass);
}
#[test]
fn assess_gamepad_required_fail() {
let cf = make_compressed(100, 0);
let input = make_input(50, 50, false);
let exp = Some(GateExpectations {
max_changed_tiles: None,
mouse_region: None,
gamepad_required: true,
});
let a = assess(&cf, &input, &exp);
assert!(!a.pass);
}
#[test]
fn assess_gamepad_required_pass() {
let cf = make_compressed(100, 0);
let input = make_input(50, 50, true);
let exp = Some(GateExpectations {
max_changed_tiles: None,
mouse_region: None,
gamepad_required: true,
});
let a = assess(&cf, &input, &exp);
assert!(a.pass);
}
#[test]
fn virtual_mapping_basic() {
let (vx, vy) = map_to_virtual(960, 540, 1920, 1080);
assert_eq!(vx, 320);
assert_eq!(vy, 240);
}
#[test]
fn virtual_mapping_zero_window() {
let (vx, vy) = map_to_virtual(100, 100, 0, 0);
assert_eq!(vx, 0);
assert_eq!(vy, 0);
}
#[test]
fn build_gate_report_structural() {
let cf = make_compressed(100, 5);
let input = make_input(100, 200, true);
let report = build_gate_report(cf, input, &None, None, None);
assert_eq!(report.status, "ok");
assert!(!report.timestamp.is_empty());
assert!(!report.screenshot_path.is_empty());
assert!(!report.capture_backend.is_empty());
assert!(report.resolution.0 > 0);
assert!(report.resolution.1 > 0);
assert!(report.compressed_frame.total_tiles > 0);
assert!(report.assessment.pass);
}
mod prop_threshold {
use super::*;
proptest! {
#[test]
fn assessment_threshold_logic(
threshold in 0u32..=1000,
changed in 0u32..=1000,
total in 1u32..=10000,
) {
let total = total.max(changed);
let cf = make_compressed(total, changed);
let input = make_input(0, 0, false);
let exp = Some(GateExpectations {
max_changed_tiles: Some(threshold),
mouse_region: None,
gamepad_required: false,
});
let a = assess(&cf, &input, &exp);
prop_assert_eq!(
a.pass,
changed <= threshold,
"changed={}, threshold={}, pass={}",
changed, threshold, a.pass
);
}
}
}
mod prop_mouse_region {
use super::*;
proptest! {
#[test]
fn assessment_mouse_region_logic(
rx in -500i32..=500,
ry in -500i32..=500,
rw in 1i32..=1000,
rh in 1i32..=1000,
mx in -1000i32..=1000,
my in -1000i32..=1000,
) {
let region = Rect { x: rx, y: ry, width: rw, height: rh };
let cf = make_compressed(100, 0);
let input = make_input(mx, my, false);
let exp = Some(GateExpectations {
max_changed_tiles: None,
mouse_region: Some(region.clone()),
gamepad_required: false,
});
let a = assess(&cf, &input, &exp);
let expected = region.contains(mx, my);
prop_assert_eq!(
a.pass,
expected,
"region=({},{},{}x{}), mouse=({},{}), contains={}, pass={}",
rx, ry, rw, rh, mx, my, expected, a.pass
);
}
}
}
mod prop_structural {
use super::*;
proptest! {
#[test]
fn gate_report_structural_completeness(
total_tiles in 1u32..=10000,
changed_tiles in 0u32..=1000,
mx in -2000i32..=2000,
my in -2000i32..=2000,
has_gamepad in proptest::bool::ANY,
) {
let changed = changed_tiles.min(total_tiles);
let cf = make_compressed(total_tiles, changed);
let input = make_input(mx, my, has_gamepad);
let report = build_gate_report(cf, input, &None, None, None);
prop_assert!(!report.status.is_empty(), "status should be non-empty");
prop_assert!(!report.timestamp.is_empty(), "timestamp should be non-empty");
prop_assert!(!report.screenshot_path.is_empty(), "screenshot_path should be non-empty");
prop_assert!(!report.capture_backend.is_empty(), "capture_backend should be non-empty");
prop_assert!(report.resolution.0 > 0, "resolution width should be positive");
prop_assert!(report.resolution.1 > 0, "resolution height should be positive");
prop_assert!(
report.compressed_frame.total_tiles > 0,
"compressed_frame.total_tiles should be > 0"
);
let _ = report.input_state.mouse.x;
let _ = report.input_state.mouse.y;
}
}
}
mod prop_burst_count {
use super::*;
proptest! {
#[test]
fn burst_gate_report_frame_count(
n_frames in 2u32..=20,
total_tiles in 1u32..=1000,
) {
let frames: Vec<CompressedFrameJson> = (0..n_frames)
.map(|i| make_compressed(total_tiles, i.min(total_tiles)))
.collect();
let primary = frames.last().cloned().unwrap();
let input = make_input(100, 200, false);
let report = build_gate_report(
primary,
input,
&None,
Some(frames.clone()),
None,
);
let burst = report.burst_frames.expect("burst_frames should be Some");
prop_assert_eq!(
burst.len() as u32,
n_frames,
"burst_frames should have exactly {} entries, got {}",
n_frames,
burst.len()
);
}
}
}
mod prop_virtual_coords {
use super::*;
proptest! {
#[test]
fn virtual_coordinate_mapping(
mouse_x in 1i32..=10000,
mouse_y in 1i32..=10000,
window_width in 1i32..=10000,
window_height in 1i32..=10000,
) {
let (vx, vy) = map_to_virtual(mouse_x, mouse_y, window_width, window_height);
let expected_vx = mouse_x * 640 / window_width;
let expected_vy = mouse_y * 480 / window_height;
prop_assert_eq!(
vx, expected_vx,
"virtual_x: mouse_x={}, window_width={}, got={}, expected={}",
mouse_x, window_width, vx, expected_vx
);
prop_assert_eq!(
vy, expected_vy,
"virtual_y: mouse_y={}, window_height={}, got={}, expected={}",
mouse_y, window_height, vy, expected_vy
);
}
}
}
}