diff --git a/tests/testthat/test-createcustomtable.R b/tests/testthat/test-createcustomtable.R
index 2ea6b69..4b775f9 100644
--- a/tests/testthat/test-createcustomtable.R
+++ b/tests/testthat/test-createcustomtable.R
@@ -275,3 +275,288 @@ test_that("use.predefined.css = FALSE leaves the spacer header cell with no matc
resDefault <- CreateCustomTable(m4, show.row.headers = FALSE, spacer.col = 2)
expect_true(grepl('.spacer {', tableHtml(resDefault), fixed = TRUE))
})
+
+# row.spans --------------------------------------------------------------
+
+# the documented @examples matrix; heights in the tests below are chosen to sum to nrow (4)
+# unless the scenario is deliberately exercising mismatched heights
+rowSpanMatrix <- structure(1:24, .Dim = c(4L, 6L),
+ .Dimnames = list(c("a", "b", "c", "d"), c("A", "B", "C", "D", "E", "F")))
+
+test_that("Default emits no rowspan cells",
+{
+ res <- CreateCustomTable(x2)
+ expect_false(grepl("rowspan=", tableHtml(res), fixed = TRUE))
+})
+
+test_that("One rowspan cell per span, with the right heights and labels",
+{
+ spans <- list(list(height = 2, label = "AA"), list(height = 1, label = "BB"),
+ list(height = 1, label = "CC"))
+ res <- CreateCustomTable(rowSpanMatrix, row.spans = spans)
+ h <- tableHtml(res)
+ # anchored with the trailing quote so rowspan="1" cannot match rowspan="10"-style values
+ expect_equal(countOccurrences('
', h), 1)
+ expect_equal(countOccurrences(' | ', h), 2)
+ expect_true(grepl(' | AA | ', h, fixed = TRUE))
+ expect_true(grepl('BB | ', h, fixed = TRUE))
+ expect_true(grepl('CC | ', h, fixed = TRUE))
+})
+
+test_that("Spans are placed at the correct row offsets",
+{
+ # heights of 2, 1, 1 mean AA's span covers rows 1-2 (leaving row 2 without its own span
+ # cell), BB opens row 3 and CC opens row 4 - pinning the j <- j + row.span.lengths[i] loop
+ spans <- list(list(height = 2, label = "AA"), list(height = 1, label = "BB"),
+ list(height = 1, label = "CC"))
+ res <- CreateCustomTable(rowSpanMatrix, row.spans = spans)
+ h <- tableHtml(res)
+ body <- sub(".*", "", h)
+ rows <- regmatches(body, gregexpr(".*?
", body))[[1]]
+ expect_equal(length(rows), 4)
+ expect_true(grepl(' | BB | ', rows[3], fixed = TRUE))
+ expect_true(grepl('
| CC | ', rows[4], fixed = TRUE))
+})
+
+test_that("A single rowspandefault CSS class is shared across all spans when sticky positioning is off",
+{
+ # NOT a production bug: addCSSclass() only expands class.css into a per-row/per-column
+ # matrix when its `position` argument is non-NULL (see addCSSclass() in
+ # createcustomtable.R). Here row.height/num.header.rows are not set, so top.position is
+ # NULL and the row-span block's scalar CSS string is never expanded - addCSSclass()
+ # derives its class count from length(class.css), so it creates a single
+ # "rowspandefault1" class that every span's reuses. (The row-header block passes the
+ # same top.position variable as its own `position` argument; on this default path
+ # top.position is also NULL, so that block collapses to a single shared class too - it
+ # only ends up with one class per row when its call *does* supply a non-NULL `position`,
+ # i.e. on the sticky path.) On the sticky path (see the next test) each span DOES
+ # get its own "rowspandefaultN" class, matching the plan's expectation.
+ spans <- list(list(height = 2, label = "AA"), list(height = 1, label = "BB"),
+ list(height = 1, label = "CC"))
+ res <- CreateCustomTable(rowSpanMatrix, row.spans = spans)
+ h <- normWs(tableHtml(res))
+ # trailing "{" anchors the declaration itself, distinct from the "rowspandefault1" token
+ # that also appears (three times, once in each span's class attribute)
+ expect_equal(countOccurrences(".rowspandefault1{", h), 1)
+ expect_equal(countOccurrences(".rowspandefault2{", h), 0)
+ expect_equal(countOccurrences('class="rowspandefault1">', h), 3)
+})
+
+test_that("Each span gets its own rowspandefault CSS class when sticky positioning is on",
+{
+ # With row.height/num.header.rows set, top.position is non-NULL, so addCSSclass()
+ # expands the row-span CSS string into one class per span (rowspandefault1/2/3) instead
+ # of sharing a single class - the counterpart to the sticky-off case above.
+ spans <- list(list(height = 2, label = "AA"), list(height = 1, label = "BB"),
+ list(height = 1, label = "CC"))
+ res <- CreateCustomTable(rowSpanMatrix, row.height = "30px", num.header.rows = 1,
+ row.spans = spans)
+ h <- normWs(tableHtml(res))
+ expect_equal(countOccurrences(".rowspandefault1{", h), 1)
+ expect_equal(countOccurrences(".rowspandefault2{", h), 1)
+ expect_equal(countOccurrences(".rowspandefault3{", h), 1)
+ expect_equal(countOccurrences(".rowspandefault4{", h), 0)
+})
+
+test_that("A per-span class is appended, not substituted",
+{
+ spans <- list(list(height = 2, label = "AA", class = "bluefill"),
+ list(height = 1, label = "BB"), list(height = 1, label = "CC"))
+ res <- CreateCustomTable(rowSpanMatrix, row.spans = spans)
+ h <- tableHtml(res)
+ expect_true(grepl(' | AA | ', h, fixed = TRUE))
+})
+
+test_that("A span without a class entry carries only the generated class",
+{
+ spans <- list(list(height = 2, label = "AA", class = "bluefill"),
+ list(height = 1, label = "BB"), list(height = 1, label = "CC"))
+ res <- CreateCustomTable(rowSpanMatrix, row.spans = spans)
+ h <- tableHtml(res)
+ # exact match (no trailing token/space) confirms nothing was appended for BB
+ expect_true(grepl('BB | ', h, fixed = TRUE))
+})
+
+test_that("Span styling arguments reach the rowspandefault CSS declaration",
+{
+ spans <- list(list(height = 2, label = "AA"), list(height = 1, label = "BB"),
+ list(height = 1, label = "CC"))
+ res <- CreateCustomTable(rowSpanMatrix, row.spans = spans, row.span.fill = "rgb(9,9,9)",
+ row.span.font.size = 21, row.span.align.horizontal = "right")
+ h <- normWs(tableHtml(res))
+ # anchor to the rowspandefault1 declaration itself, not just anywhere in the document,
+ # so the assertions cannot be satisfied by an unrelated CSS rule
+ block <- regmatches(h, regexpr(".rowspandefault1[{][^}]*[}]", h))
+ expect_true(grepl("background: rgb(9,9,9)", block, fixed = TRUE))
+ expect_true(grepl("font-size: 21px", block, fixed = TRUE))
+ expect_true(grepl("text-align: right", block, fixed = TRUE))
+})
+
+test_that("row.spans prepends an extra header cell (ncol + 2 th cells)",
+{
+ spans <- list(list(height = 2, label = "AA"), list(height = 1, label = "BB"),
+ list(height = 1, label = "CC"))
+ # a distinct corner label lets ths[1] (row-span corner, always blank) and ths[2]
+ # (row-header corner, carries `corner`) be told apart even if their order were swapped
+ res <- CreateCustomTable(rowSpanMatrix, row.spans = spans, corner = "RH")
+ h <- tableHtml(res)
+ thead <- sub(".*", "", h)
+ ths <- regmatches(thead, gregexpr('[^<]* | ', thead))[[1]]
+ # 6 data columns + row-header corner + row-span corner
+ expect_equal(length(ths), 8)
+ expect_equal(ths[1], ' | ')
+ expect_equal(ths[2], 'RH | ')
+})
+
+test_that("Adding row.spans increases (does not reduce) the emitted sticky-position count",
+{
+ # PLAN DEVIATION: the plan expected the rm.index pruning at the top of the row.spans
+ # block to REDUCE the sticky-position count relative to the no-spans case. In practice
+ # that pruning trims the top.position vector, which is reassigned and then also fed
+ # into the row-span column's OWN addCSSclass() call; the cell/row-header sticky counts
+ # (computed earlier in the function) are unaffected. (A table combining row.spans with
+ # col.spans would also see the col-span classes affected, since the later column-span
+ # block passes the same pruned top.position as its position argument.) Because the
+ # row-span column also gets a "position: sticky" class of its own, adding row.spans
+ # increases the total sticky count by 1 (for num.header.rows = 1, the minimal
+ # combination) rather than decreasing it. Pinning the actual, confirmed behaviour
+ # instead of the plan's assumption.
+ # NOTE: at num.header.rows = 1, top.position has length 1, so the emitted output is the
+ # same either way: for the heights used here (2, 1, 1) rm.index holds only out-of-range
+ # indices (>= 2), so `top.position[-rm.index]` is a no-op. If every span height were 1,
+ # rm.index would stay NULL and `top.position[-rm.index]` (i.e. `top.position[-NULL]`)
+ # would error rather than silently no-op - but that all-heights-1 combination is never
+ # exercised at num.header.rows = 1 by this test. See the num.header.rows = 2 case below
+ # for the pruning branch actually removing an index.
+ spans <- list(list(height = 2, label = "AA"), list(height = 1, label = "BB"),
+ list(height = 1, label = "CC"))
+ noSpans <- CreateCustomTable(rowSpanMatrix, row.height = "30px", num.header.rows = 1)
+ withSpans <- CreateCustomTable(rowSpanMatrix, row.height = "30px", num.header.rows = 1,
+ row.spans = spans)
+ countNoSpans <- countOccurrences("position: sticky", tableHtml(noSpans))
+ countWithSpans <- countOccurrences("position: sticky", tableHtml(withSpans))
+ expect_equal(countWithSpans, countNoSpans + 1)
+})
+
+test_that("The rm.index pruning branch is actually exercised at num.header.rows = 2",
+{
+ # At num.header.rows = 2, top.position has length 2, so rm.index pruning is only a
+ # no-op when no span's offset (height - 1) lands on index 2. A front-loaded span
+ # (heights 2, 1, 1) prunes index 2 out of top.position; a back-loaded span
+ # (heights 1, 1, 2) does not prune anything, since its offset lands past top.position's
+ # length. Confirmed counts: no spans -> noSpans, front-loaded (pruned) -> noSpans + 1,
+ # back-loaded (not pruned) -> noSpans + 2. Expressed relative to noSpans (rather than
+ # hardcoded absolutes) so an unrelated CSS addition elsewhere doesn't make this brittle.
+ frontLoaded <- list(list(height = 2, label = "AA"), list(height = 1, label = "BB"),
+ list(height = 1, label = "CC"))
+ backLoaded <- list(list(height = 1, label = "AA"), list(height = 1, label = "BB"),
+ list(height = 2, label = "CC"))
+ resNoSpans <- CreateCustomTable(rowSpanMatrix, row.height = "30px", num.header.rows = 2)
+ withFront <- CreateCustomTable(rowSpanMatrix, row.height = "30px", num.header.rows = 2,
+ row.spans = frontLoaded)
+ withBack <- CreateCustomTable(rowSpanMatrix, row.height = "30px", num.header.rows = 2,
+ row.spans = backLoaded)
+ noSpans <- countOccurrences("position: sticky", tableHtml(resNoSpans))
+ expect_equal(countOccurrences("position: sticky", tableHtml(withFront)), noSpans + 1)
+ expect_equal(countOccurrences("position: sticky", tableHtml(withBack)), noSpans + 2)
+})
+
+test_that("Span heights not summing to nrow are pinned to current (unvalidated) behaviour",
+{
+ # under-covering: heights sum to 2 against nrow = 4, so rows 3-4 simply get no span cell
+ spans <- list(list(height = 1, label = "AA"), list(height = 1, label = "BB"))
+ res <- CreateCustomTable(rowSpanMatrix, row.spans = spans)
+ h <- tableHtml(res)
+ body <- sub(".*", "", h)
+ rows <- regmatches(body, gregexpr("
|---|
.*?
", body))[[1]]
+ expect_equal(length(rows), 4)
+ expect_true(grepl('| AA | ', rows[1], fixed = TRUE))
+ expect_true(grepl('
| BB | ', rows[2], fixed = TRUE))
+ expect_false(grepl("rowspan", rows[3], fixed = TRUE))
+ expect_false(grepl("rowspan", rows[4], fixed = TRUE))
+
+ # over-covering: a single span with a height greater than nrow completes without error;
+ # there is no bounds check in the j <- j + row.span.lengths[i] loop, but with only one
+ # span the loop makes just one assignment (row.span.html[1] <- ...) before it ends, so
+ # `j` only overruns nrow AFTER that final (and only) span - row.span.html never grows
+ # beyond its original length and no misalignment occurs here. See the multi-span case
+ # below for what happens when a later span's assignment lands past the overrun.
+ overSpans <- list(list(height = 10, label = "AA"))
+ res <- CreateCustomTable(rowSpanMatrix, row.spans = overSpans)
+ h <- tableHtml(res)
+ body <- sub(".*", "", h)
+ rows <- regmatches(body, gregexpr("
.*?
", body))[[1]]
+ expect_equal(length(rows), 4)
+ expect_true(grepl('| AA | ', rows[1], fixed = TRUE))
+ expect_false(grepl("rowspan", rows[2], fixed = TRUE))
+ expect_false(grepl("rowspan", rows[3], fixed = TRUE))
+ expect_false(grepl("rowspan", rows[4], fixed = TRUE))
+})
+
+test_that("A multi-span over-cover silently drops the last span and leaks a cbind warning (genuine defect, not yet ticketed)",
+{
+ # Genuine production defect being pinned here, not the plan-authoring mistake above:
+ # when a span's height pushes `j` (in the `j <- j + row.span.lengths[i]` loop) past
+ # nrow, the next assignment to row.span.html[j] silently grows that vector beyond
+ # nrows (here to length 12, for the height-1/10/3 spans below) - so CC's span is written
+ # past the end of the table and effectively discarded, rather than misaligning rows 3-4
+ # (the emitted rowspan attribute keeps its literal, oversized value - e.g. rowspan="10" -
+ # and it is the browser's HTML renderer, not this code, that clamps it to the remaining
+ # rows, so the table shape itself stays correct). cbind(row.span.html, cell.html) then
+ # warns because the result's row count
+ # (4, taken from cell.html) is not a multiple of row.span.html's length (12). No ticket
+ # has been filed for this yet.
+ overSpans <- list(list(height = 1, label = "AA"), list(height = 10, label = "BB"),
+ list(height = 3, label = "CC"))
+ expect_warning(
+ res <- CreateCustomTable(rowSpanMatrix, row.spans = overSpans),
+ "number of rows of result is not a multiple of vector length", fixed = TRUE
+ )
+ h <- tableHtml(res)
+ body <- sub(".*", "", h)
+ rows <- regmatches(body, gregexpr("
.*?
", body))[[1]]
+ expect_equal(length(rows), 4)
+ expect_true(grepl('| AA | ', rows[1], fixed = TRUE))
+ expect_true(grepl('
| BB | ', rows[2], fixed = TRUE))
+ expect_false(grepl("rowspan", rows[3], fixed = TRUE))
+ expect_false(grepl("rowspan", rows[4], fixed = TRUE))
+})
+
+test_that("All-height-1 row.spans with sticky positioning errors on invalid unary -NULL (genuine unticketed defect)",
+{
+ # Genuine production crash being pinned here (not yet ticketed): rm.index is only
+ # populated for spans taller than one row, so when EVERY span height is 1, rm.index
+ # stays NULL, and the pruning expression `top.position[-rm.index]` becomes
+ # `top.position[-NULL]`. In base R, unary minus on NULL is invalid ("invalid argument
+ # to unary operator"), so the function throws rather than returning the original
+ # top.position unchanged. This only happens when sticky positioning is also active
+ # (row.height set AND num.header.rows set), since top.position is only computed - and
+ # only fed into the row.spans pruning branch - in that case. If this defect is ever
+ # fixed, this expect_error should be changed to assert successful output instead.
+ allOnes <- list(list(height = 1, label = "AA"), list(height = 1, label = "BB"),
+ list(height = 1, label = "CC"), list(height = 1, label = "DD"))
+ expect_error(
+ CreateCustomTable(rowSpanMatrix, row.spans = allOnes, row.height = "30px",
+ num.header.rows = 1),
+ "invalid argument to unary operator", fixed = TRUE
+ )
+
+ # Same all-height-1 spans without sticky positioning: top.position/rm.index pruning is
+ # never reached, so this succeeds - the crash is specific to the sticky combination,
+ # not to row.spans (or all-height-1 spans) in general.
+ expect_error(res <- CreateCustomTable(rowSpanMatrix, row.spans = allOnes), NA)
+ expect_true(grepl('AA | ', tableHtml(res),
+ fixed = TRUE))
+
+ # Mixed heights (2, 1, 1) with the same sticky args: rm.index is populated (the height-2
+ # span), so the pruning expression is a normal subscript, not `-NULL`, and this succeeds.
+ mixedHeights <- list(list(height = 2, label = "AA"), list(height = 1, label = "BB"),
+ list(height = 1, label = "CC"))
+ expect_error(
+ CreateCustomTable(rowSpanMatrix, row.spans = mixedHeights, row.height = "30px",
+ num.header.rows = 1),
+ NA
+ )
+})