From edc677c00a0c76dc41d1cf4626d837b52680f3dc Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 17 Sep 2026 18:31:13 +0800 Subject: [PATCH] test(layout): verify native table heights and aligned painting --- .../runtime/page_vm/tests/rendering_update.rs | 72 +++++++++++++++++++ .../tests/fixtures/table-part-dimensions.html | 22 +----- 2 files changed, 75 insertions(+), 19 deletions(-) diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs index 542d9f0f68..27feb222d3 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs @@ -2872,6 +2872,78 @@ async fn screenshot_preserves_table_cell_dimension_hints_and_avatar_columns() { .expect("table-cell dimension hints should preserve avatar alignment"); } +#[tokio::test(flavor = "current_thread")] +async fn screenshot_table_row_heights_match_chromium() { + run_page_vm_async_test(async move { + let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default())?; + let mut page = test_page_vm_with_loader_and_document_url( + &loader, Vec::new(), Url::parse("https://example.com/table-row-heights.html")?, + ); + page.vm_mut().set_layout_policy(moli_page_types::LayoutPolicy::OnDemand); + let fixture = include_str!("../../../../tests/fixtures/table-row-heights.html"); + page.vm_mut().eval(&format!("document.open();document.write({});document.close()", serde_json::to_string(fixture)?))?; + page.vm_mut().prime_document_lifecycle_processing_and_record_stylesheet_network_results(); + page.vm_mut().eval("if (!cases.length) buildTableRowHeightCases()")?; + let expected: serde_json::Value = serde_json::from_str(include_str!("../../../../tests/fixtures/table-row-heights.chromium.json"))?; + let mut failures = Vec::new(); + for phase in 0..3 { + page.vm_mut().eval(&format!("setTableRowHeightPhase({phase})"))?; + // Both initial layout and a repeated read must agree with the oracle. + for read in 0..2 { + page.vm_mut().screenshot_layout_snapshot(moli_layout::PaintViewport::new(800, 600, 1.0))?.expect("table layout root"); + let actual: serde_json::Value = serde_json::from_str(&page.vm_mut().eval("JSON.stringify(collectTableRowHeights())")?)?; + let actual = actual.as_array().unwrap(); + let expected_cases = expected["phases"][phase].as_array().unwrap(); + assert_eq!(actual.len(), expected_cases.len()); + for (actual, expected) in actual.iter().zip(expected_cases) { + assert_eq!(actual["name"], expected["name"]); + let name = actual["name"].as_str().unwrap(); + let actual_rects = actual["rects"].as_array().unwrap(); + let expected_rects = expected["rects"].as_array().unwrap(); + assert_eq!(actual_rects.len(), expected_rects.len()); + for (index, (actual, expected)) in actual_rects.iter().zip(expected_rects).enumerate() { + if (0..4).any(|axis| (actual[axis].as_f64().unwrap() - expected[axis].as_f64().unwrap()).abs() > 0.05) { + failures.push(format!("phase {phase}, read {read}, {name}, rect {index}: {actual} != {expected}")); + } + } + } + } + } + assert!(failures.is_empty(), "{} table geometry differences:\n{}", failures.len(), failures.join("\n")); + // Check that final percentage-dependent baseline alignment reaches + // paint as well as CSSOM, including a subsequent group-height change. + page.vm_mut().eval(r#" + for (const {name,owner} of cases) owner.style.display = name === 'percent-baseline' ? 'block' : 'none'; + const paintedCase = cases.find(c => c.name === 'percent-baseline').owner; + paintedCase.querySelectorAll('td > div')[0].style.background = 'rgb(255,0,0)'; + paintedCase.querySelectorAll('td > div')[1].style.background = 'rgb(0,255,0)'; + "#)?; + for height in [120u32, 160] { + page.vm_mut().eval(&format!("paintedCase.querySelector('tbody').style.height = '{height}px'"))?; + let snapshot = page.vm_mut().screenshot_layout_snapshot(moli_layout::PaintViewport::new(240, 200, 1.0))?.expect("painted table root"); + let image = moli_paint::raster_snapshot(&snapshot)?; + for (x, y, color) in [(5, height / 4, [255, 0, 0, 255]), (105, height / 2 - 10, [0, 255, 0, 255])] { + let offset = ((y * image.width + x) * 4) as usize; + assert_eq!(&image.rgba[offset..offset + 4], color, "group height {height}, pixel ({x}, {y})"); + } + } + page.vm_mut().eval(r#" + paintedCase.style.display = 'none'; + const borderedCase = cases.find(c => c.name === 'empty-groups-collapsed').owner; + borderedCase.style.display = 'block'; + borderedCase.querySelector('thead').style.height = '80px'; + borderedCase.querySelector('tfoot').style.height = '80px'; + "#)?; + let snapshot = page.vm_mut().screenshot_layout_snapshot(moli_layout::PaintViewport::new(240, 200, 1.0))?.expect("collapsed table root"); + let image = moli_paint::raster_snapshot(&snapshot)?; + for (x, y) in [(100, 1), (100, 187), (1, 100)] { + let offset = ((y * image.width + x) * 4) as usize; + assert_eq!(&image.rgba[offset..offset + 4], [0, 0, 255, 255], "collapsed border at ({x}, {y})"); + } + Ok::<_, anyhow::Error>(()) + }).await.expect("table row heights should match Chromium"); +} + #[tokio::test(flavor = "current_thread")] async fn screenshot_recascades_table_part_dimension_hints() { run_page_vm_async_test(async move { diff --git a/moli-renderer-v8/tests/fixtures/table-part-dimensions.html b/moli-renderer-v8/tests/fixtures/table-part-dimensions.html index 0288c465ef..f96e7715d8 100644 --- a/moli-renderer-v8/tests/fixtures/table-part-dimensions.html +++ b/moli-renderer-v8/tests/fixtures/table-part-dimensions.html @@ -36,7 +36,6 @@ ]; const checks = []; const mutations = []; - const cssHeightReferences = []; for (const dimension of dimensions) { for (const test of cases) { const owner = document.createElement('div'); @@ -64,10 +63,8 @@ } } - // Table/row heights size real rows; zero preserves the content minimum. - // Moli's table formatter currently ignores CSS row-group heights too, so - // compare those native groups with authored CSS. The block cases above - // independently verify the hint values, including percentages and zero. + // Native table, row and row-group hints constrain actual row geometry. + // Zero/removal preserves the content minimum (Chromium 147 oracle). for (const tag of ['table','thead','tbody','tfoot','tr']) { const table = document.createElement('table'); table.style.width = '200px'; @@ -81,15 +78,6 @@ document.body.append(table); const check = {id:`native-${tag}`, element:row, expected:[[200,32],[200,64],[200,8],[200,8]]}; - if (element === section) { - const reference = table.cloneNode(true); - const referenceGroup = reference.querySelector(tag); - referenceGroup.removeAttribute('height'); - referenceGroup.style.height = '32px'; - document.body.append(reference); - check.reference = reference.rows[0]; - cssHeightReferences.push(referenceGroup); - } checks.push(check); mutations.push({element, names:['height']}); } @@ -127,18 +115,14 @@ else if (phase === 3) element.setAttribute(name, '0'); } } - for (const element of cssHeightReferences) { - element.style.height = ['32px','64px','auto','0px'][phase]; - } } function collectTablePartDimensionChecks() { return checks.map(check => { const rect = check.element && check.element.getBoundingClientRect(); - const reference = check.reference && check.reference.getBoundingClientRect(); return {id:check.id, actual:check.cells ? check.cells.map(cell => cell.getBoundingClientRect().width) : [rect.width,rect.height], - expected:reference ? [reference.width,reference.height] : check.expected[dimensionPhase]}; + expected:check.expected[dimensionPhase]}; }); }