mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 22:27:13 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
343 lines
23 KiB
Swift
343 lines
23 KiB
Swift
#if canImport(UIKit)
|
||
import XCTest
|
||
import UIKit
|
||
@testable import RichTextEditorUIKit
|
||
import RichTextEditorCore
|
||
|
||
final class CanvasTableHeaderTests: XCTestCase {
|
||
func cell(_ id: String, _ t: String) -> Cell {
|
||
Cell(id: BlockID(id), blocks: [.paragraph(ParagraphBlock(id: BlockID(id + "p"), runs: [TextRun(text: t)]))])
|
||
}
|
||
func test_headerRowFlagAndRendersNonBlank() {
|
||
let v = DocumentCanvasView()
|
||
v.setBlocks([.table(TableBlock(id: BlockID("t"), columns: [ColumnSpec(width: 140), ColumnSpec(width: 140)],
|
||
rows: [Row(id: BlockID("r0"), isHeader: true, cells: [cell("a", "Name"), cell("b", "Role")]),
|
||
Row(id: BlockID("r1"), cells: [cell("c", "Ada"), cell("d", "Eng")])]))], width: 340)
|
||
v.frame = CGRect(x: 0, y: 0, width: 340, height: 400); v.layoutIfNeeded()
|
||
let t = v.boxes.first as! TableBlockBox
|
||
XCTAssertTrue(t.isHeaderRow(0))
|
||
XCTAssertFalse(t.isHeaderRow(1))
|
||
let image = UIGraphicsImageRenderer(bounds: v.bounds).image { _ in v.drawHierarchy(in: v.bounds, afterScreenUpdates: true) }
|
||
XCTAssertNotNil(image.cgImage)
|
||
}
|
||
|
||
func test_firstRowBold_othersNot_modelStaysClean() {
|
||
let v = DocumentCanvasView()
|
||
v.setBlocks([.table(TableBlock(id: BlockID("t"), columns: [ColumnSpec(width: 140), ColumnSpec(width: 140)],
|
||
rows: [Row(id: BlockID("r0"), isHeader: true, cells: [cell("a","Name"), cell("b","Role")]),
|
||
Row(id: BlockID("r1"), cells: [cell("c","Ada"), cell("d","Eng")])]))], width: 340)
|
||
v.frame = CGRect(x: 0, y: 0, width: 340, height: 400); v.layoutIfNeeded()
|
||
let t = v.boxes[0] as! TableBlockBox
|
||
func bold(_ r: Int, _ c: Int) -> Bool {
|
||
let s = t.cells[r][c].boxes[0] as! BlockBox
|
||
let f = s.layout.attributedString.attribute(.font, at: 0, effectiveRange: nil) as! UIFont
|
||
return f.fontDescriptor.symbolicTraits.contains(.traitBold)
|
||
}
|
||
XCTAssertTrue(bold(0, 0)) // header row → bold (render-only)
|
||
XCTAssertTrue(bold(0, 1)) // header row → bold
|
||
XCTAssertFalse(bold(1, 0)) // body row → not bold
|
||
XCTAssertFalse(bold(1, 1)) // body row → not bold
|
||
// model stays clean: no synthetic bold persisted in the header row
|
||
guard case .table(let tb) = t.currentBlock(),
|
||
case .paragraph(let p) = tb.rows[0].cells[0].blocks[0] else { return XCTFail() }
|
||
XCTAssertFalse(p.runs[0].attributes.bold)
|
||
}
|
||
|
||
func test_perCellHeader_partialRow_fillsAndBoldsOnlyHeaderCells() {
|
||
func hcell(_ id: String, _ t: String) -> Cell {
|
||
var c = cell(id, t); c.isHeader = true; return c
|
||
}
|
||
let v = DocumentCanvasView()
|
||
// Row 0: cell (0,0) header, cell (0,1) NOT header — a partial-header row (impossible under the old model).
|
||
v.setBlocks([.table(TableBlock(id: BlockID("t"), columns: [ColumnSpec(width: 140), ColumnSpec(width: 140)],
|
||
rows: [Row(id: BlockID("r0"), cells: [hcell("a", "H"), cell("b", "x")]),
|
||
Row(id: BlockID("r1"), cells: [cell("c", "y"), cell("d", "z")])]))], width: 340)
|
||
v.frame = CGRect(x: 0, y: 0, width: 340, height: 400); v.layoutIfNeeded()
|
||
let t = v.boxes[0] as! TableBlockBox
|
||
XCTAssertTrue(t.isHeaderCell(0, 0))
|
||
XCTAssertFalse(t.isHeaderCell(0, 1))
|
||
XCTAssertFalse(t.isHeaderRow(0), "a partially-header row is not a header row")
|
||
func bold(_ r: Int, _ c: Int) -> Bool {
|
||
let s = t.cells[r][c].boxes[0] as! BlockBox
|
||
let f = s.layout.attributedString.attribute(.font, at: 0, effectiveRange: nil) as! UIFont
|
||
return f.fontDescriptor.symbolicTraits.contains(.traitBold)
|
||
}
|
||
XCTAssertTrue(bold(0, 0)) // header cell → bold
|
||
XCTAssertFalse(bold(0, 1)) // non-header cell → not bold
|
||
// model round-trips the per-cell flag and stays clean (no synthetic bold persisted)
|
||
guard case .table(let tb) = t.currentBlock() else { return XCTFail() }
|
||
XCTAssertTrue(tb.rows[0].cells[0].isHeader)
|
||
XCTAssertFalse(tb.rows[0].cells[1].isHeader)
|
||
if case .paragraph(let p) = tb.rows[0].cells[0].blocks[0] { XCTAssertFalse(p.runs[0].attributes.bold) }
|
||
}
|
||
|
||
/// RGBA8 (premultiplied-last, deviceRGB) sample at image point (x, y). Same pixel-buffer mechanism as
|
||
/// `ImageSelectionHighlightTests.pixels(of:)`, narrowed to a single-point read.
|
||
private func sample(_ image: CGImage, x: Int, y: Int) -> (r: Int, g: Int, b: Int, a: Int) {
|
||
let w = image.width, h = image.height
|
||
var px = [UInt8](repeating: 0, count: w * h * 4)
|
||
let ctx = CGContext(data: &px, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4,
|
||
space: CGColorSpaceCreateDeviceRGB(),
|
||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
|
||
ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))
|
||
let i = (min(max(y, 0), h - 1) * w + min(max(x, 0), w - 1)) * 4
|
||
return (Int(px[i]), Int(px[i + 1]), Int(px[i + 2]), Int(px[i + 3]))
|
||
}
|
||
|
||
// Rendering-level coverage of `TableBlockBox.draw()`'s per-cell fill pass (flags + bold alone can't catch
|
||
// an index/precedence/accumulator bug in the fill). Renders the table's own draw() into an image at canvas
|
||
// coords (scale 1, exactly what TableContentView.drawBlockContents drives) and pixel-samples three cells:
|
||
// a header cell (tint), a plain body cell (no fill), and a header cell with an explicit background (bg wins).
|
||
func test_draw_perCellFillPass_headerTint_bodyClear_explicitBackgroundWins() {
|
||
func hcell(_ id: String, _ t: String) -> Cell { var c = cell(id, t); c.isHeader = true; return c }
|
||
var redHeader = cell("b", "R") // header AND an explicit opaque red background
|
||
redHeader.isHeader = true
|
||
redHeader.background = RGBAColor(red: 1, green: 0, blue: 0, alpha: 1)
|
||
let v = DocumentCanvasView()
|
||
v.setBlocks([.table(TableBlock(id: BlockID("t"), columns: [ColumnSpec(width: 140), ColumnSpec(width: 140)],
|
||
rows: [Row(id: BlockID("r0"), cells: [hcell("a", "H"), redHeader]),
|
||
Row(id: BlockID("r1"), cells: [cell("c", "y"), cell("d", "z")])]))], width: 340)
|
||
v.frame = CGRect(x: 0, y: 0, width: 340, height: 400); v.layoutIfNeeded()
|
||
let t = v.boxes[0] as! TableBlockBox
|
||
let fmt = UIGraphicsImageRendererFormat(); fmt.opaque = false; fmt.scale = 1
|
||
let image = UIGraphicsImageRenderer(bounds: v.bounds, format: fmt).image { ctx in
|
||
t.draw(in: ctx.cgContext, imageProvider: { _ in nil })
|
||
}.cgImage!
|
||
func center(_ r: Int, _ c: Int) -> (x: Int, y: Int) {
|
||
let rect = t.cellRect(row: r, column: c)!
|
||
return (Int(rect.midX), Int(rect.midY))
|
||
}
|
||
let (hx, hy) = center(0, 0), (bx, by) = center(1, 0), (rx, ry) = center(0, 1)
|
||
let header = sample(image, x: hx, y: hy) // header, no explicit bg → header tint
|
||
let body = sample(image, x: bx, y: by) // plain body cell → no fill
|
||
let red = sample(image, x: rx, y: ry) // header + explicit red bg → red wins (precedence)
|
||
// (1) the header cell is tinted where the plain body cell is not (translucent tint over a clear bg).
|
||
XCTAssertEqual(body.a, 0, "a plain body cell draws no fill (stays transparent)")
|
||
XCTAssertGreaterThan(header.a, 0, "a header cell draws the translucent header tint")
|
||
XCTAssertTrue(header.a != body.a || header.r != body.r || header.g != body.g || header.b != body.b,
|
||
"header-cell pixel differs from the plain body-cell pixel")
|
||
// (2) an explicit cell background WINS over the header tint: dominated by opaque red, not a gray tint.
|
||
XCTAssertGreaterThan(red.r, 200, "explicit-bg cell is red")
|
||
XCTAssertLessThan(red.g, 80); XCTAssertLessThan(red.b, 80)
|
||
XCTAssertGreaterThan(red.a, 200, "the explicit opaque background fills the cell fully")
|
||
}
|
||
|
||
// MARK: - Phase 2b Task 5: span-aware fill + per-segment grid lines
|
||
|
||
/// RGBA (0-255) of a `UIColor`, resolved against the CURRENT trait environment — matches whatever
|
||
/// `UIGraphicsImageRenderer` (no explicit trait collection) rendered the theme's dynamic colors against,
|
||
/// so the expected border color agrees with what actually landed in the pixel buffer.
|
||
private func rgba(_ color: UIColor) -> (r: Int, g: Int, b: Int, a: Int) {
|
||
let resolved = color.resolvedColor(with: UITraitCollection.current)
|
||
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||
resolved.getRed(&r, green: &g, blue: &b, alpha: &a)
|
||
return (Int((r * 255).rounded()), Int((g * 255).rounded()), Int((b * 255).rounded()), Int((a * 255).rounded()))
|
||
}
|
||
|
||
/// Un-premultiplies an RGBA8 sample (the pixel buffer is `premultipliedLast`, so a semi-transparent
|
||
/// stroke's raw R/G/B bytes are scaled down by its alpha — comparing them directly against an opaque
|
||
/// theme color would read as a false mismatch). No-op at alpha 0 (returns as-is; the "no divider" case
|
||
/// checks `.a == 0` directly, never the color, so this value is never inspected there).
|
||
private func unpremultiplied(_ px: (r: Int, g: Int, b: Int, a: Int)) -> (r: Int, g: Int, b: Int, a: Int) {
|
||
guard px.a > 0 else { return px }
|
||
let factor = 255.0 / Double(px.a)
|
||
func scaled(_ c: Int) -> Int { min(255, Int((Double(c) * factor).rounded())) }
|
||
return (scaled(px.r), scaled(px.g), scaled(px.b), px.a)
|
||
}
|
||
|
||
/// The highest-alpha pixel within `±radius` of `(x, y)` along the given axis — a 1pt stroke centered on
|
||
/// a half-pixel boundary anti-aliases its coverage across TWO adjacent pixel columns/rows (~50% alpha
|
||
/// each, never a clean 255), so "is a divider drawn here" must look at the peak nearby alpha, not the
|
||
/// exact sampled coordinate.
|
||
private func peakAlphaNear(_ image: CGImage, x: Int, y: Int, radius: Int, horizontal: Bool) -> (r: Int, g: Int, b: Int, a: Int) {
|
||
var best = sample(image, x: x, y: y)
|
||
for d in -radius...radius {
|
||
let px = horizontal ? sample(image, x: x + d, y: y) : sample(image, x: x, y: y + d)
|
||
if px.a > best.a { best = px }
|
||
}
|
||
return best
|
||
}
|
||
|
||
private func renderTable(_ t: TableBlockBox) -> CGImage {
|
||
let fmt = UIGraphicsImageRendererFormat(); fmt.opaque = false; fmt.scale = 1
|
||
let bounds = CGRect(origin: .zero, size: CGSize(width: t.frame.maxX, height: t.frame.maxY))
|
||
return UIGraphicsImageRenderer(bounds: bounds, format: fmt).image { ctx in
|
||
t.draw(in: ctx.cgContext, imageProvider: { _ in nil })
|
||
}.cgImage!
|
||
}
|
||
|
||
private func makeTable(width: CGFloat, height: CGFloat, table: TableBlock) -> TableBlockBox {
|
||
let t = TableBlockBox(table: table, mapper: AttributedStringMapper(), width: width)
|
||
t.frame = CGRect(x: 0, y: 0, width: width, height: height)
|
||
t.recompute(); t.recompute()
|
||
return t
|
||
}
|
||
|
||
private func hcell(_ id: String, _ t: String) -> Cell { var c = cell(id, t); c.isHeader = true; return c }
|
||
|
||
/// A 3-column x 2-row table where row 0's first two cells (PER-CELL header, NOT a whole header row —
|
||
/// so the row's third cell stays plain) merge into one colspan-2 header anchor; row 1 stays fully dense.
|
||
/// Deliberately NOT a uniform header row: under the OLD per-slot fill, the merge's declared array index 1
|
||
/// (physical column 2, "H2"/plain) happens to land ITS OWN dense fill exactly over the merged cell's
|
||
/// covered physical-column-1 slot whenever every column is the same width AND every cell in the row is a
|
||
/// header (both true in the first, since-fixed draft of this fixture) — a false pass that doesn't
|
||
/// actually exercise the anchor-resolution fix. Making cell "c" NON-header removes that coincidence: the
|
||
/// covered slot is filled ONLY if the fill pass correctly resolves it to the merged anchor.
|
||
private func mergedHeaderTable() -> TableBlock {
|
||
let dense = TableBlock(id: BlockID("t"),
|
||
columns: [ColumnSpec(width: 100), ColumnSpec(width: 100), ColumnSpec(width: 100)],
|
||
rows: [
|
||
Row(id: BlockID("r0"), cells: [hcell("a", "H0"), hcell("b", "H1"), cell("c", "H2")]),
|
||
Row(id: BlockID("r1"), cells: [cell("d", "D"), cell("e", "E"), cell("f", "F")]),
|
||
])
|
||
return dense.mergingCells(in: TableRect(top: 0, left: 0, bottom: 0, right: 1))
|
||
}
|
||
|
||
/// A colspan-2 HEADER anchor must fill its WHOLE spanned rect — including the covered (second) column
|
||
/// slot, which a naive per-slot fill (iterating `cells[r][c]` and filling only the declared origin
|
||
/// slot) would leave unfilled/clear.
|
||
func test_draw_mergedHeaderCell_fillsWholeSpannedRect() {
|
||
let t = makeTable(width: 300, height: 300, table: mergedHeaderTable())
|
||
let image = renderTable(t)
|
||
// The merged anchor's own (first) column, within its row-0 band.
|
||
let anchorX = Int(t.cellRect(row: 1, column: 0)!.midX)
|
||
let row0Y = Int(t.cellRect(row: 0, column: 0)!.midY)
|
||
// The SECOND (covered) column of the same merged cell, same row-0 band.
|
||
let coveredX = Int(t.cellRect(row: 1, column: 1)!.midX)
|
||
// A non-merged, non-header body cell.
|
||
let bodyX = Int(t.cellRect(row: 1, column: 2)!.midX)
|
||
let row1Y = Int(t.cellRect(row: 1, column: 2)!.midY)
|
||
|
||
let anchorPixel = sample(image, x: anchorX, y: row0Y)
|
||
let coveredPixel = sample(image, x: coveredX, y: row0Y)
|
||
let bodyPixel = sample(image, x: bodyX, y: row1Y)
|
||
|
||
XCTAssertGreaterThan(anchorPixel.a, 0, "the anchor's own column shows the header tint")
|
||
XCTAssertGreaterThan(coveredPixel.a, 0,
|
||
"the SECOND (covered) column of the merged header cell also shows the tint — " +
|
||
"a per-slot fill would leave this clear")
|
||
XCTAssertEqual(bodyPixel.a, 0, "a non-merged, non-header body cell draws no fill")
|
||
}
|
||
|
||
/// A 2-column x 2-row table with row 0 merged into one colspan-2 anchor (row 1 stays dense), no header/
|
||
/// background fill — isolates the border logic from the fill pass.
|
||
private func mergedRowTable() -> TableBlock {
|
||
let dense = TableBlock(id: BlockID("t"), columns: [ColumnSpec(width: 100), ColumnSpec(width: 100)],
|
||
rows: [Row(id: BlockID("r0"), cells: [cell("a", "A"), cell("b", "B")]),
|
||
Row(id: BlockID("r1"), cells: [cell("c", "C"), cell("d", "D")])])
|
||
return dense.mergingCells(in: TableRect(top: 0, left: 0, bottom: 0, right: 1))
|
||
}
|
||
|
||
/// The interior vertical divider between columns 0/1 must be SUPPRESSED within the merged row-0 band
|
||
/// (no anchor straddles it there — one anchor covers both slots) but must still be PRESENT in the dense
|
||
/// row-1 band (the control), proving suppression is per-segment, not table-wide.
|
||
func test_draw_noInteriorDividerInsideMergedCell() {
|
||
let t = makeTable(width: 300, height: 300, table: mergedRowTable())
|
||
let image = renderTable(t)
|
||
let border = rgba(t.mapper.theme.tableBorder)
|
||
// Boundary x between column 0 and column 1 (row 1's dense cell's right edge + half a border).
|
||
let boundaryX = Int((t.cellRect(row: 1, column: 0)!.maxX + TableBlockBox.border / 2).rounded())
|
||
let mergedRowY = Int(t.cellRect(row: 0, column: 0)!.midY)
|
||
let denseRowY = Int(t.cellRect(row: 1, column: 0)!.midY)
|
||
|
||
let mergedBoundaryPixel = peakAlphaNear(image, x: boundaryX, y: mergedRowY, radius: 1, horizontal: true)
|
||
let denseBoundaryPixel = unpremultiplied(peakAlphaNear(image, x: boundaryX, y: denseRowY, radius: 1, horizontal: true))
|
||
|
||
XCTAssertEqual(mergedBoundaryPixel.a, 0,
|
||
"no divider is drawn INSIDE the merged cell's row band (no fill/border there either)")
|
||
XCTAssertGreaterThan(denseBoundaryPixel.a, 80,
|
||
"control: the SAME vertical boundary in the dense row-1 band still draws the divider")
|
||
XCTAssertLessThanOrEqual(abs(denseBoundaryPixel.r - border.r), 40)
|
||
XCTAssertLessThanOrEqual(abs(denseBoundaryPixel.g - border.g), 40)
|
||
XCTAssertLessThanOrEqual(abs(denseBoundaryPixel.b - border.b), 40)
|
||
}
|
||
|
||
/// Dense parity: a fully dense table (no spans) still renders every interior divider (vertical AND
|
||
/// horizontal) plus the per-cell header fill — proving the per-segment/anchor-driven rewrite is
|
||
/// byte-behaviorally unchanged for the no-span case.
|
||
func test_draw_denseTable_rendersAllDividersAndHeaderFill() {
|
||
let dense = TableBlock(id: BlockID("t"), columns: [ColumnSpec(width: 100), ColumnSpec(width: 100)],
|
||
rows: [Row(id: BlockID("r0"), isHeader: true, cells: [cell("a", "H0"), cell("b", "H1")]),
|
||
Row(id: BlockID("r1"), cells: [cell("c", "C"), cell("d", "D")])])
|
||
let t = makeTable(width: 300, height: 300, table: dense)
|
||
let image = renderTable(t)
|
||
let border = rgba(t.mapper.theme.tableBorder)
|
||
|
||
let headerPixel = sample(image, x: Int(t.cellRect(row: 0, column: 0)!.midX), y: Int(t.cellRect(row: 0, column: 0)!.midY))
|
||
XCTAssertGreaterThan(headerPixel.a, 0, "the header row still fills with the header tint")
|
||
|
||
// Interior vertical divider (columns 0/1), sampled in BOTH row bands.
|
||
let vBoundaryX = Int((t.cellRect(row: 0, column: 0)!.maxX + TableBlockBox.border / 2).rounded())
|
||
for (label, y) in [("row0", Int(t.cellRect(row: 0, column: 0)!.midY)), ("row1", Int(t.cellRect(row: 1, column: 0)!.midY))] {
|
||
let best = unpremultiplied(peakAlphaNear(image, x: vBoundaryX, y: y, radius: 1, horizontal: true))
|
||
XCTAssertGreaterThan(best.a, 80, "dense parity: vertical divider present in \(label)")
|
||
XCTAssertLessThanOrEqual(abs(best.r - border.r), 40, "dense parity: divider color matches the theme border in \(label)")
|
||
}
|
||
|
||
// Interior horizontal divider (rows 0/1).
|
||
let hBoundaryY = Int((t.cellRect(row: 0, column: 0)!.maxY + TableBlockBox.border / 2).rounded())
|
||
let hx = Int(t.cellRect(row: 0, column: 0)!.midX)
|
||
let bestH = unpremultiplied(peakAlphaNear(image, x: hx, y: hBoundaryY, radius: 1, horizontal: false))
|
||
XCTAssertGreaterThan(bestH.a, 80, "dense parity: horizontal divider present between rows 0/1")
|
||
XCTAssertLessThanOrEqual(abs(bestH.r - border.r), 40)
|
||
}
|
||
|
||
/// A dense 2×2 table with INTEGER geometry (columns [100,100] at a 203pt total → 100pt columns exactly;
|
||
/// explicit tall row heights → integer row bands), so the interior 4-way grid crossing lands on a clean
|
||
/// pixel. The `border×border` crossing square where the vertical and horizontal dividers meet MUST be
|
||
/// painted the border color — the pre-fix per-segment code clipped each segment to its own content band
|
||
/// and left this square a transparent hole (the defect this test guards).
|
||
func test_draw_interiorCrossing_isPainted_dense() {
|
||
// width 203 = 2·100 + 3·border → columns solve to exactly [100,100] (no scale, both ≥ minColumnWidth).
|
||
let dense = TableBlock(id: BlockID("t"), columns: [ColumnSpec(width: 100), ColumnSpec(width: 100)],
|
||
rows: [Row(id: BlockID("r0"), height: 80, cells: [cell("a", "A"), cell("b", "B")]),
|
||
Row(id: BlockID("r1"), height: 80, cells: [cell("c", "C"), cell("d", "D")])])
|
||
let t = makeTable(width: 203, height: 300, table: dense)
|
||
let image = renderTable(t)
|
||
let border = rgba(t.mapper.theme.tableBorder)
|
||
|
||
// The interior crossing = the corner shared by cells (0,0)/(0,1)/(1,0)/(1,1). With integer geometry
|
||
// the `border×border` crossing square occupies exactly ONE pixel: the column band [maxX, maxX+border]
|
||
// × the row band [maxY, maxY+border] (i.e. pixel (maxX, maxY) since border == 1). This must be
|
||
// sampled EXACTLY, radius 0 — a neighborhood search would catch an adjacent (non-crossing) segment
|
||
// pixel and mask the hole.
|
||
let r00 = t.cellRect(row: 0, column: 0)!
|
||
let cx = Int(r00.maxX), cy = Int(r00.maxY)
|
||
let crossing = unpremultiplied(sample(image, x: cx, y: cy))
|
||
XCTAssertGreaterThan(crossing.a, 200, "the interior 4-way crossing square is painted (not a hole)")
|
||
XCTAssertLessThanOrEqual(abs(crossing.r - border.r), 40, "the crossing is the border color")
|
||
XCTAssertLessThanOrEqual(abs(crossing.g - border.g), 40)
|
||
XCTAssertLessThanOrEqual(abs(crossing.b - border.b), 40)
|
||
}
|
||
|
||
/// The crossing where the SUPPRESSED-then-RESUMED vertical divider (suppressed inside the colspan-2 row-0
|
||
/// cell, resumed in the dense row-1 band) meets the horizontal divider between rows 0 and 1 must also be
|
||
/// painted: a merge suppresses the divider only WITHIN the merged cell, never the junction below it.
|
||
func test_draw_interiorCrossing_isPainted_belowMergedCell() {
|
||
// Row 0 merged colspan-2, row 1 dense, integer geometry (width 203, explicit row heights).
|
||
let dense = TableBlock(id: BlockID("t"), columns: [ColumnSpec(width: 100), ColumnSpec(width: 100)],
|
||
rows: [Row(id: BlockID("r0"), height: 80, cells: [cell("a", "A"), cell("b", "B")]),
|
||
Row(id: BlockID("r1"), height: 80, cells: [cell("c", "C"), cell("d", "D")])])
|
||
let merged = dense.mergingCells(in: TableRect(top: 0, left: 0, bottom: 0, right: 1))
|
||
let t = makeTable(width: 203, height: 300, table: merged)
|
||
let image = renderTable(t)
|
||
let border = rgba(t.mapper.theme.tableBorder)
|
||
|
||
// Sanity: the vertical divider IS suppressed inside the merged row-0 cell (no hole to fill there).
|
||
let r10 = t.cellRect(row: 1, column: 0)!
|
||
let boundaryX = Int((r10.maxX + TableBlockBox.border / 2).rounded())
|
||
let insideMerged = peakAlphaNear(image, x: boundaryX, y: Int(t.cellRect(row: 0, column: 0)!.midY), radius: 1, horizontal: true)
|
||
XCTAssertEqual(insideMerged.a, 0, "sanity: no vertical divider inside the merged row-0 cell")
|
||
|
||
// The crossing at (col boundary, row 0/1 boundary): the resumed vertical divider (row-1 band) meets
|
||
// the horizontal divider. The crossing square is the column band [maxX, maxX+border] × the row band
|
||
// ABOVE row 1's top ([minY-border, minY]) — pixel (maxX, minY-border) with border == 1. Sampled
|
||
// EXACTLY (radius 0) so the hole can't be masked by an adjacent segment pixel.
|
||
let cx = Int(r10.maxX), cy = Int(r10.minY) - Int(TableBlockBox.border)
|
||
let crossing = unpremultiplied(sample(image, x: cx, y: cy))
|
||
XCTAssertGreaterThan(crossing.a, 200, "the crossing below the merged cell is painted (not a hole)")
|
||
XCTAssertLessThanOrEqual(abs(crossing.r - border.r), 40, "the crossing is the border color")
|
||
}
|
||
}
|
||
#endif
|