visual-rubric 0.2.0

AI-assisted screenshot rubric runner for local visual UX review
Documentation
use std::cell::RefCell;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;

use crate::{LogCaptureConfig, LogPathMode, RubricVerdictStatus};

use super::*;

fn snapshot(entries: &[(&str, &str)]) -> AssetSnapshot {
    AssetSnapshot::from_hashes(entries.iter().map(|(path, hash)| (*path, *hash)))
}

#[test]
fn diff_snapshots_classifies_added_changed_deleted_and_unchanged() {
    let before = snapshot(&[
        ("deleted.png", "a"),
        ("changed.png", "a"),
        ("same.png", "a"),
    ]);
    let after = snapshot(&[("added.png", "a"), ("changed.png", "b"), ("same.png", "a")]);

    let changes = diff_snapshots(&before, &after);

    assert_eq!(
        changes,
        vec![
            AssetChange::Added(PathBuf::from("added.png")),
            AssetChange::Changed(PathBuf::from("changed.png")),
            AssetChange::Deleted(PathBuf::from("deleted.png")),
            AssetChange::Unchanged(PathBuf::from("same.png")),
        ]
    );
}

#[test]
fn select_changed_respects_selection_mode() {
    let changes = vec![
        AssetChange::Added(PathBuf::from("added.png")),
        AssetChange::Changed(PathBuf::from("changed.png")),
        AssetChange::Deleted(PathBuf::from("deleted.png")),
        AssetChange::Unchanged(PathBuf::from("same.png")),
    ];

    assert_eq!(
        select_changed(&changes, SelectionMode::ChangedOnly),
        vec![PathBuf::from("added.png"), PathBuf::from("changed.png")]
    );
    assert_eq!(
        select_changed(&changes, SelectionMode::IncludeUnchanged),
        vec![
            PathBuf::from("added.png"),
            PathBuf::from("changed.png"),
            PathBuf::from("same.png"),
        ]
    );
}

#[test]
fn aggregate_status_prefers_error_then_fail_then_pass_then_skipped() {
    let skipped = vec![AssetRubricReport::skipped(
        Path::new("a.png"),
        "unchanged",
        "same",
    )];
    assert_eq!(aggregate_status(&skipped), AggregateStatus::Skipped);

    let pass = vec![AssetRubricReport::selected(
        Path::new("a.png"),
        "changed",
        AssetRubricResult::Pass {
            reason: "ok".to_owned(),
            anomalies: Vec::new(),
        },
    )];
    assert_eq!(aggregate_status(&pass), AggregateStatus::Pass);

    let fail = vec![AssetRubricReport::selected(
        Path::new("a.png"),
        "changed",
        AssetRubricResult::Fail {
            reason: "bad".to_owned(),
            anomalies: Vec::new(),
        },
    )];
    assert_eq!(aggregate_status(&fail), AggregateStatus::Fail);

    let error = vec![AssetRubricReport::selected(
        Path::new("a.png"),
        "changed",
        AssetRubricResult::Error {
            message: "worker failed".to_owned(),
        },
    )];
    assert_eq!(aggregate_status(&error), AggregateStatus::Error);
}

struct FakeEvaluator {
    results: RefCell<Vec<Result<RubricVerdict, PoolError>>>,
}

impl BatchEvaluator for FakeEvaluator {
    fn submit_asset(&self, _png_path: &Path, _question: &str) -> Result<RubricVerdict, PoolError> {
        self.results.borrow_mut().remove(0)
    }
}

#[test]
fn batch_partial_error_preserves_completed_and_marks_remaining() {
    let changes = vec![
        AssetChange::Changed(PathBuf::from("a.png")),
        AssetChange::Changed(PathBuf::from("b.png")),
        AssetChange::Changed(PathBuf::from("c.png")),
    ];
    let evaluator = FakeEvaluator {
        results: RefCell::new(vec![
            Ok(RubricVerdict {
                verdict: RubricVerdictStatus::from("pass"),
                reason: "ok".to_owned(),
                anomalies: Vec::new(),
            }),
            Err(PoolError::Timeout {
                worker_id: 0,
                timeout: Duration::from_secs(180),
            }),
        ]),
    };
    let run = BatchRubricRun::new(BatchRubricConfig {
        pool: PoolConfig {
            workers: 1,
            ..PoolConfig::default()
        },
        question: "question".to_owned(),
        selection_mode: SelectionMode::ChangedOnly,
        classifier: None,
    });

    let report = run.run_with_evaluator(&changes, Some(&evaluator));

    assert_eq!(report.aggregate_status, AggregateStatus::Error);
    assert!(matches!(
        report.assets[0].result,
        AssetRubricResult::Pass { .. }
    ));
    assert!(matches!(
        report.assets[1].result,
        AssetRubricResult::Error { .. }
    ));
    assert!(matches!(
        report.assets[2].result,
        AssetRubricResult::NotEvaluatedAfterError { .. }
    ));
}

#[test]
fn log_capture_copies_sqlite_wal_and_session_jsonl() {
    let temp = tempfile::tempdir().expect("tempdir");
    let source = temp.path().join("tmp");
    let output = temp.path().join("logs");
    let session_dir = source.join("worker/sessions/2026/06/08");
    fs::create_dir_all(&session_dir).expect("session dir");
    fs::write(source.join("logs_1.sqlite"), "sqlite").expect("sqlite");
    fs::write(source.join("logs_1.sqlite-wal"), "wal").expect("wal");
    fs::write(session_dir.join("run.jsonl"), "{}").expect("jsonl");
    fs::write(source.join("ignore.txt"), "ignore").expect("ignore");

    let logs = copy_logs_from_config(Some(&LogCaptureConfig {
        temp_dir: source,
        output_dir: output,
        path_mode: LogPathMode::RelativeTo(temp.path().to_path_buf()),
    }))
    .expect("copy logs");

    assert_eq!(
        logs,
        vec![
            "logs/logs_1.sqlite".to_owned(),
            "logs/logs_1.sqlite-wal".to_owned(),
            "logs/worker/sessions/2026/06/08/run.jsonl".to_owned(),
        ]
    );
}

#[test]
fn log_capture_copy_error_is_reported() {
    let temp = tempfile::tempdir().expect("tempdir");
    let source = temp.path().join("tmp");
    fs::create_dir_all(&source).expect("source dir");
    fs::write(source.join("logs_1.sqlite"), "sqlite").expect("sqlite");
    let output_file = temp.path().join("logs");
    fs::write(&output_file, "not a directory").expect("output file");

    let run = BatchRubricRun::new(BatchRubricConfig {
        pool: PoolConfig {
            workers: 1,
            log_capture: Some(LogCaptureConfig {
                temp_dir: source,
                output_dir: output_file,
                path_mode: LogPathMode::Absolute,
            }),
            ..PoolConfig::default()
        },
        question: "question".to_owned(),
        selection_mode: SelectionMode::ChangedOnly,
        classifier: None,
    });

    let report = run.run(&[]);

    assert!(report.logs.is_empty());
    let error = report
        .log_capture_error
        .as_deref()
        .expect("log capture error");
    assert!(error.contains("copy configured ACP logs"));
}

struct TestClassifier;

impl IssueClassifier for TestClassifier {
    fn classify(&self, input: IssueClassificationInput<'_>) -> Vec<IssueRecommendation> {
        vec![IssueRecommendation {
            id: "text-clipping".to_owned(),
            class: "text_clipping".to_owned(),
            severity: RecommendationSeverity::Medium,
            affected_assets: vec![input.asset.path.clone()],
            evidence: vec![input.issue_text.to_owned()],
            suggested_fix: "reserve more margin".to_owned(),
            candidate_modules: vec!["renderer".to_owned()],
        }]
    }
}

#[test]
fn classifier_recommendations_are_merged_by_id() {
    let assets = vec![
        AssetRubricReport::selected(
            Path::new("a.png"),
            "changed",
            AssetRubricResult::Fail {
                reason: "label clipped".to_owned(),
                anomalies: Vec::new(),
            },
        ),
        AssetRubricReport::selected(
            Path::new("b.png"),
            "changed",
            AssetRubricResult::Fail {
                reason: "text clipped".to_owned(),
                anomalies: Vec::new(),
            },
        ),
    ];

    let recommendations = classify_recommendations(Some(&TestClassifier), &assets);

    assert_eq!(recommendations.len(), 1);
    assert_eq!(
        recommendations[0].affected_assets,
        vec!["a.png".to_owned(), "b.png".to_owned()]
    );
}