Files
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:57:09 +01:00

343 lines
23 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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