Files
pbakaus_impeccable/crates/html/tests/placeholder_contrast.rs
T
cb56ed6c19 Fix: detect placeholder contrast (#790) (#799)
* Fix: detect placeholder contrast (#790)

`detect` never read `::placeholder` color, so pale placeholders passed. Score them with the same WCAG math as body text, without host class/clip heuristics.

Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: match descendant ::placeholder hosts (#790)

`.form ::placeholder` kept the ancestor as the host. Reuse the hover combinator star-fill so the color lands on the inputs inside.

Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: placeholder-shown and gradient alpha (#790)

Browser scans skip when :placeholder-shown is false, so a live filled field does not keep the HTML value attribute's empty state. Translucent placeholders flatten over each gradient stop before scoring.

Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: trailing combinator only for ::placeholder hosts (#790)

`star_empty_compounds` turned `.label + ::placeholder` into `.label *+*`. Fill only a trailing empty compound so adjacent-sibling hosts still match.

Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 13:49:59 -07:00

123 lines
3.9 KiB
Rust

//! Integration tests for `::placeholder` contrast detection (#790).
use impeccable_html::{detect_html_source, DetectHtmlOptions};
use std::path::Path;
const ISSUER_REPRO: &str = r#"<!DOCTYPE html>
<html><head><style>
input::placeholder { color: #bbbbbb; }
input { background: white; font-size: 16px; width: 200px; height: 40px; border: 1px solid #ccc; padding: 8px; box-sizing: border-box; }
</style></head>
<body><input placeholder="Search"></body></html>
"#;
fn scan(html: &str) -> Vec<impeccable_core::findings::Finding> {
detect_html_source(html, Path::new("/tmp/placeholder.html"), &DetectHtmlOptions::default())
}
fn repo_root() -> std::path::PathBuf {
std::env::var("IMPECCABLE_PUBLIC_REPO")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."))
}
#[test]
fn issuer_repro_flags_pale_placeholder() {
let findings = scan(ISSUER_REPRO);
assert!(
findings.iter().any(|f| f.antipattern == "low-contrast"),
"expected low-contrast finding, got {findings:?}"
);
}
#[test]
fn bare_placeholder_selector_flags() {
let html = r#"<!DOCTYPE html>
<html><head><style>
::placeholder { color: #bbbbbb; }
input { background: white; font-size: 16px; width: 200px; height: 40px; }
</style></head>
<body><input placeholder="Search"></body></html>
"#;
let findings = scan(html);
assert!(
findings.iter().any(|f| f.antipattern == "low-contrast"),
"expected low-contrast for bare ::placeholder, got {findings:?}"
);
}
#[test]
fn descendant_placeholder_selector_flags() {
let html = r#"<!DOCTYPE html>
<html><head><style>
.form ::placeholder { color: #bbbbbb; }
input { background: white; font-size: 16px; width: 200px; height: 40px; }
</style></head>
<body><div class="form"><input placeholder="Search"></div></body></html>
"#;
let findings = scan(html);
assert!(
findings.iter().any(|f| f.antipattern == "low-contrast"),
"expected low-contrast for descendant ::placeholder, got {findings:?}"
);
}
#[test]
fn sibling_placeholder_selector_flags() {
let html = r#"<!DOCTYPE html>
<html><head><style>
.label + ::placeholder { color: #bbbbbb; }
input { background: white; font-size: 16px; width: 200px; height: 40px; }
</style></head>
<body><label class="label">Name</label><input placeholder="Search"></body></html>
"#;
let findings = scan(html);
assert!(
findings.iter().any(|f| f.antipattern == "low-contrast"),
"expected low-contrast for sibling ::placeholder, got {findings:?}"
);
}
#[test]
fn fixture_flag_and_pass_cases() {
let fixture = repo_root().join("tests/fixtures/antipatterns/placeholder-contrast.html");
assert!(
fixture.is_file(),
"missing fixture at {}",
fixture.display()
);
let html = std::fs::read_to_string(&fixture).unwrap();
let findings = detect_html_source(&html, &fixture, &DetectHtmlOptions::default());
let ids: Vec<&str> = findings.iter().map(|f| f.antipattern.as_str()).collect();
let snippets: Vec<&str> = findings.iter().map(|f| f.snippet.as_str()).collect();
for needle in [
"Pale Placeholder On White Field",
"Pale Placeholder On White Textarea",
"Translucent Placeholder On Light Field",
"Pale Placeholder On Frosted Panel",
] {
assert!(
snippets.iter().any(|s| s.contains(needle)),
"expected flag for placeholder {needle:?}, findings={findings:?}"
);
}
for needle in [
"Ink Placeholder On White Field",
"Light Placeholder On Dark Field",
"Filled Field Hides Placeholder",
"Unstyled Placeholder Uses UA Color",
] {
assert!(
!snippets.iter().any(|s| s.contains(needle)),
"pass case {needle:?} should not flag, findings={findings:?}"
);
}
assert!(
ids.iter().filter(|id| **id == "low-contrast").count() >= 4,
"expected at least four low-contrast hits, got {findings:?}"
);
}