diff --git a/CLAUDE.md b/CLAUDE.md index 5eedad6..d77a888 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,12 +169,17 @@ footer (footer_left + footer_center + footer_right) ### Text normalization — `normalize_text(x)` -Accepts: `NULL`, single string, character vector. +Accepts: `NULL`, a single `NA`, single string, character vector. Returns: `list(text = , nlines = )` - Character vector → collapsed with `"\n"` - Embedded `"\n"` → counted for `nlines` - `NULL` → `list(text = NULL, nlines = 0L)` +- A single `NA` (`.is_single_na()`, any atomic type) → treated the same as + `NULL`. This lets data-driven page construction pass `NA` for an unsupplied + annotation. The same rule applies to `page_i` (no `"Page : "` prefix) and + to `normalize_rule()` (a single `NA` means no rule, i.e. `FALSE`). A longer + vector with `NA` among real values is left untouched. ### Height measurement — `measure_grob_height(grob, nlines)` diff --git a/R/export_tfl_page.R b/R/export_tfl_page.R index dc96c1c..9fbf432 100644 --- a/R/export_tfl_page.R +++ b/R/export_tfl_page.R @@ -20,17 +20,21 @@ #' of this gap and do not consume additional space. #' @param header_left,header_center,header_right Header text. Accepts #' `NULL`, a single string, or a character vector (collapsed with `"\\n"`). +#' A single `NA` is treated the same as `NULL` (section element absent), so +#' that data-driven page construction can leave an element unset. #' Horizontal justification follows the argument name (left/center/right). #' Vertically top-justified. Overridden by `x$header_left` etc. #' @param caption Caption text below the header and above the content. Accepts -#' `NULL`, a single string, or a character vector. Full-width; justification -#' controlled by `caption_just`. Overridden by `x$caption`. +#' `NULL`, a single `NA` (treated as `NULL`), a single string, or a character +#' vector. Full-width; justification controlled by `caption_just`. Overridden +#' by `x$caption`. #' @param footnote Footnote text below the content. Accepts `NULL`, a single -#' string, or a character vector. Full-width; justification controlled by -#' `footnote_just`. Overridden by `x$footnote`. +#' `NA` (treated as `NULL`), a single string, or a character vector. +#' Full-width; justification controlled by `footnote_just`. Overridden by +#' `x$footnote`. #' @param footer_left,footer_center,footer_right Footer text. Mirror of -#' header arguments. Vertically bottom-justified. Overridden by -#' `x$footer_left` etc. +#' header arguments (a single `NA` is treated as `NULL`). Vertically +#' bottom-justified. Overridden by `x$footer_left` etc. #' @param gp Typography specification. Accepts either a single `gpar()` object #' applied to all text, or a named list for section- or element-level #' control. Resolution priority (highest first): element-level @@ -46,7 +50,7 @@ #' ``` #' @param header_rule Separator rule drawn between the header and the next #' section (caption or content), fitted within the `padding` gap. Accepts: -#' - `FALSE`: no rule +#' - `FALSE` (or a single `NA`): no rule #' - `TRUE`: full-width rule #' - A numeric in `(0, 1]`: rule spanning that fraction of viewport width, #' centered @@ -81,8 +85,9 @@ #' single column — or any data column combined with the row-header #' group columns — wider than the page). #' @param page_i Integer page index, used to prefix layout error messages with -#' `"Page : "`. Set automatically by [writetfl::export_tfl()]; -#' not normally supplied when calling this function directly. +#' `"Page : "`. A single `NA` is treated the same as `NULL` (no prefix). +#' Set automatically by [writetfl::export_tfl()]; not normally supplied when +#' calling this function directly. #' @param preview Logical. If `TRUE`, calls `grid.newpage()` and draws to the #' currently open device without opening or closing any device. #' @param newpage Logical. If `TRUE` (default), start the page with @@ -173,6 +178,9 @@ export_tfl_page <- function( footnote_just <- match.arg(footnote_just, c("left", "right", "centre")) content_just <- match.arg(content_just, c("left", "right", "centre")) overflow_action <- match.arg(overflow_action, c("error", "warn")) + # A single NA page index behaves as "unset" (no "Page : " error prefix), + # matching the NA-as-NULL treatment of the annotation arguments. + if (.is_single_na(page_i)) page_i <- NULL # --------------------------------------------------------------------------- # 2. Normalize all text and rule inputs diff --git a/R/normalize.R b/R/normalize.R index 7c0917a..ba59048 100644 --- a/R/normalize.R +++ b/R/normalize.R @@ -1,14 +1,30 @@ # normalize.R — Input normalization helpers # See ARCHITECTURE.md for full contracts. +#' Test whether `x` is a single `NA` to be treated as absent (like `NULL`) +#' +#' A length-1 atomic vector holding `NA` (of any type: logical `NA`, +#' `NA_character_`, `NA_integer_`, ...). Used so that data-driven page +#' construction can pass `NA` for an unsupplied annotation or toggle and have +#' it treated the same as `NULL`. A longer vector containing `NA` among real +#' values is *not* a single `NA` and is left untouched. +#' +#' @param x Any object. +#' @return `TRUE` only when `x` is a length-1 atomic `NA`. +#' @keywords internal +.is_single_na <- function(x) { + is.atomic(x) && length(x) == 1L && is.na(x) +} + #' Normalize text input to a single string with line count #' -#' @param x NULL, a single character string, or a character vector. +#' @param x `NULL`, a single `NA` (treated as `NULL`), a single character +#' string, or a character vector. #' @return A list with elements `text` (single string or NULL) and #' `nlines` (integer). #' @keywords internal normalize_text <- function(x) { - if (is.null(x) || (is.character(x) && length(x) == 0L)) { + if (is.null(x) || .is_single_na(x) || (is.character(x) && length(x) == 0L)) { return(list(text = NULL, nlines = 0L)) } text <- paste(x, collapse = "\n") @@ -39,13 +55,15 @@ wrap_normalized_text <- function(norm, gp, width_in, ...) { #' Normalize rule specification to FALSE or a grob #' -#' @param x FALSE, TRUE, numeric in (0,1], or a grob. +#' @param x FALSE, TRUE, numeric in (0,1], or a grob. A single `NA` is treated +#' the same as `FALSE` (no rule), so that data-driven construction can leave +#' the toggle unset. #' A `linesGrob` is the typical choice, but any grob is accepted and will be #' drawn as-is (centered vertically in the padding gap). #' @return FALSE or a grob. #' @keywords internal normalize_rule <- function(x) { - if (isFALSE(x)) { + if (isFALSE(x) || .is_single_na(x)) { return(FALSE) } diff --git a/design/ARCHITECTURE.md b/design/ARCHITECTURE.md index e278740..52bc86d 100644 --- a/design/ARCHITECTURE.md +++ b/design/ARCHITECTURE.md @@ -389,21 +389,35 @@ export_tfl(x = list_of_table1, ...) [exported] ## Data contracts +### `.is_single_na(x)` → `logical(1)` + +``` +Input: any object +Output: TRUE only when x is a length-1 atomic NA (of any type) +``` + +Shared predicate that lets a single `NA` be treated as "absent" (like `NULL`) +across the annotation/toggle arguments, so data-driven page construction can +pass `NA` for an unsupplied value. A longer vector containing `NA` among real +values is NOT a single `NA` and is left untouched. + ### `normalize_text(x)` → `list` ``` -Input: NULL | character(1) | character(n) +Input: NULL | NA (single) | character(1) | character(n) Output: list( text = character(1) | NULL, # \n-collapsed string nlines = integer(1) # 0 if NULL ) ``` +A single `NA` (`.is_single_na()`) is treated the same as `NULL`. + ### `normalize_rule(x)` → `FALSE | grob` ``` -Input: FALSE | TRUE | numeric (0,1] | grob (typically linesGrob) -Output: FALSE (no rule) +Input: FALSE | NA (single) | TRUE | numeric (0,1] | grob (typically linesGrob) +Output: FALSE (no rule; also for a single NA) | grob (ready to draw, centered in padding gap) ``` diff --git a/design/DECISIONS.md b/design/DECISIONS.md index f1ca9c5..86cbe68 100644 --- a/design/DECISIONS.md +++ b/design/DECISIONS.md @@ -2126,3 +2126,47 @@ raw-byte helper (no `pdftools` dependency) and regression tests asserting a single-page `tfl_table` yields exactly one PDF page, that a figure and a table yield the same page count, and that multi-page tables carry no leading blank. + +--- + +## D-52: A single `NA` is treated as `NULL`/absent for annotation & toggle args + +**Decision:** In `export_tfl_page()`, a length-1 atomic `NA` (of any type) +supplied for an annotation or presence-toggle argument is treated exactly like +`NULL` (the "absent" state). This covers `caption`, `footnote`, +`header_left/center/right`, `footer_left/center/right` (section element +absent), `page_i` (no `"Page : "` error prefix), and `header_rule` / +`footer_rule` (no rule, i.e. `FALSE`). Implemented once via the shared +predicate `.is_single_na()` in `R/normalize.R`, consumed by `normalize_text()`, +`normalize_rule()`, and the `page_i` coercion in `export_tfl_page()`. Because +the annotation arguments flow through `normalize_text()` after the `x[[key]]` +override merge, this also lets a per-page `x$caption = NA` blank an +otherwise-global caption. + +**Motivation:** Page lists are frequently built from data frames (one row per +page). A column feeding an annotation naturally carries `NA` for pages where +the value is not applicable; without this, `NA` renders as the literal text +`"NA"` or errors. Treating a lone `NA` as absent makes data-driven +construction ergonomic. + +**Scope — arguments deliberately left to fail loudly on `NA`:** +- `padding`, `margins`, `min_content_height` — `unit` objects; an `NA` is a + malformed value, not an "unset" signal, and `checkmate::assert_class()` + rejects it. +- `caption_just`, `footnote_just`, `content_just`, `overflow_action` — + enumerated settings validated by `match.arg()`; they always have a concrete + default, so an `NA` signals a construction bug and should error rather than + silently pick a default. (Per the fail-loud principle in the global + robustness guidance.) +- `gp`, `preview`, `newpage`, `x` — not presence toggles; `NA` is not a + meaningful "absent" value for them. + +**Boundary:** Only a *single* `NA` is absent. A longer vector containing `NA` +among real values (e.g. `c("a", NA, "b")`) is untouched — it collapses with +`"\n"` exactly as before, so genuine multi-line content is never silently +dropped. + +**Tests:** `tests/testthat/test-normalize.R` covers `.is_single_na()`, +`normalize_text(NA)`, and `normalize_rule(NA)`; `test-export_tfl_page.R` covers +the end-to-end NA-as-absent behavior for each argument and the mixed-vector +boundary. diff --git a/design/TESTING.md b/design/TESTING.md index 895e11d..3089fe2 100644 --- a/design/TESTING.md +++ b/design/TESTING.md @@ -32,7 +32,7 @@ One test file per source file — `tests/testthat/test-.R` covers | `test-draw.R` | `draw_content()`, `draw_rule()`, `draw_header_section()`, `draw_footer_section()`, `draw_caption_section()`, `draw_footnote_section()` | | `test-grob_builders.R` | `build_text_grob()`, `build_section_grobs()` | | `test-export_tfl.R` | `export_tfl()` — file validation, return values, preview mode, device lifecycle, tfl_table coercion, argument merging | -| `test-export_tfl_page.R` | `export_tfl_page()` — argument resolution from x, overlap_warn_mm, page_i prefix, section presence, rules, page-level grob overflow under `overflow_action` (issue #30) | +| `test-export_tfl_page.R` | `export_tfl_page()` — argument resolution from x, overlap_warn_mm, page_i prefix, section presence, rules, page-level grob overflow under `overflow_action` (issue #30), single-`NA`-as-absent for every annotation, `page_i`, and rule (D-52; asserts no literal `"NA"` reaches the page via a display-list label sweep) | | `test-table_utils.R` | `.compute_group_sizes()`, `.collect_col_strings()`, `.measure_max_string_width()`, `.wrap_text()` (now a default-breaks shim) | | `test-wrap.R` | `wrap_breaks()` constructor + validation; `.tokenize_for_wrap()` (drop / keep_before / mixed); `.leading_drop_run()`; `.convert_tabs()` (leading vs. in-line tab expansion, custom counts); `.wrap_string()` (paragraphs, single token, keep_before, leading-space preservation as a hanging indent, tab expansion); `.column_has_breakable_text()`; `.column_min_token_width_in()` (floor calculation, keep_before reduces floor); `.wrap_label_for_width()`; `.compute_wrapped_widths()` (no-eligible no-op, water-from-top widest-first, longest-token floor) | | `test-table_draw.R` | `build_table_grob()`, `drawDetails.tfl_table_grob()` (uncached fallback, wrap branch, rotated col_cont_msg labels, first_data fallback) | @@ -58,12 +58,19 @@ test_that("normalize_text counts embedded newlines in nlines", ...) test_that("normalize_text counts lines in collapsed vector correctly", ...) test_that("normalize_text handles empty string", ...) test_that("normalize_text handles character(0)", ...) +test_that("normalize_text treats a single NA the same as NULL", ...) # D-52 +test_that("normalize_text keeps NA embedded in a longer vector", ...) # D-52 + +# .is_single_na() — D-52 +test_that(".is_single_na is TRUE only for a length-1 atomic NA", ...) +test_that(".is_single_na is FALSE for NULL, real values, multi-element NA", ...) # normalize_rule() test_that("normalize_rule returns FALSE for FALSE", ...) test_that("normalize_rule returns linesGrob for TRUE", ...) test_that("normalize_rule returns linesGrob for numeric 0.5", ...) test_that("normalize_rule errors for numeric outside (0,1]", ...) +test_that("normalize_rule treats a single NA the same as FALSE", ...) # D-52 test_that("normalize_rule passes linesGrob through unchanged", ...) test_that("normalize_rule errors for invalid input type", ...) ``` diff --git a/man/dot-is_single_na.Rd b/man/dot-is_single_na.Rd new file mode 100644 index 0000000..dacb3e1 --- /dev/null +++ b/man/dot-is_single_na.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/normalize.R +\name{.is_single_na} +\alias{.is_single_na} +\title{Test whether \code{x} is a single \code{NA} to be treated as absent (like \code{NULL})} +\usage{ +.is_single_na(x) +} +\arguments{ +\item{x}{Any object.} +} +\value{ +\code{TRUE} only when \code{x} is a length-1 atomic \code{NA}. +} +\description{ +A length-1 atomic vector holding \code{NA} (of any type: logical \code{NA}, +\code{NA_character_}, \code{NA_integer_}, ...). Used so that data-driven page +construction can pass \code{NA} for an unsupplied annotation or toggle and have +it treated the same as \code{NULL}. A longer vector containing \code{NA} among real +values is \emph{not} a single \code{NA} and is left untouched. +} +\keyword{internal} diff --git a/man/export_tfl.Rd b/man/export_tfl.Rd index 66163ca..f42d64e 100644 --- a/man/export_tfl.Rd +++ b/man/export_tfl.Rd @@ -99,17 +99,21 @@ captures it as an inline graphic). Returns \code{NULL} invisibly.} of this gap and do not consume additional space.} \item{\code{header_left,header_center,header_right}}{Header text. Accepts \code{NULL}, a single string, or a character vector (collapsed with \code{"\\\\n"}). +A single \code{NA} is treated the same as \code{NULL} (section element absent), so +that data-driven page construction can leave an element unset. Horizontal justification follows the argument name (left/center/right). Vertically top-justified. Overridden by \code{x$header_left} etc.} \item{\code{caption}}{Caption text below the header and above the content. Accepts -\code{NULL}, a single string, or a character vector. Full-width; justification -controlled by \code{caption_just}. Overridden by \code{x$caption}.} +\code{NULL}, a single \code{NA} (treated as \code{NULL}), a single string, or a character +vector. Full-width; justification controlled by \code{caption_just}. Overridden +by \code{x$caption}.} \item{\code{footnote}}{Footnote text below the content. Accepts \code{NULL}, a single -string, or a character vector. Full-width; justification controlled by -\code{footnote_just}. Overridden by \code{x$footnote}.} +\code{NA} (treated as \code{NULL}), a single string, or a character vector. +Full-width; justification controlled by \code{footnote_just}. Overridden by +\code{x$footnote}.} \item{\code{footer_left,footer_center,footer_right}}{Footer text. Mirror of -header arguments. Vertically bottom-justified. Overridden by -\code{x$footer_left} etc.} +header arguments (a single \code{NA} is treated as \code{NULL}). Vertically +bottom-justified. Overridden by \code{x$footer_left} etc.} \item{\code{gp}}{Typography specification. Accepts either a single \code{gpar()} object applied to all text, or a named list for section- or element-level control. Resolution priority (highest first): element-level @@ -126,7 +130,7 @@ control. Resolution priority (highest first): element-level \item{\code{header_rule}}{Separator rule drawn between the header and the next section (caption or content), fitted within the \code{padding} gap. Accepts: \itemize{ -\item \code{FALSE}: no rule +\item \code{FALSE} (or a single \code{NA}): no rule \item \code{TRUE}: full-width rule \item A numeric in \verb{(0, 1]}: rule spanning that fraction of viewport width, centered diff --git a/man/export_tfl_page.Rd b/man/export_tfl_page.Rd index 45bdc35..388645c 100644 --- a/man/export_tfl_page.Rd +++ b/man/export_tfl_page.Rd @@ -46,20 +46,24 @@ of this gap and do not consume additional space.} \item{header_left, header_center, header_right}{Header text. Accepts \code{NULL}, a single string, or a character vector (collapsed with \code{"\\\\n"}). +A single \code{NA} is treated the same as \code{NULL} (section element absent), so +that data-driven page construction can leave an element unset. Horizontal justification follows the argument name (left/center/right). Vertically top-justified. Overridden by \code{x$header_left} etc.} \item{caption}{Caption text below the header and above the content. Accepts -\code{NULL}, a single string, or a character vector. Full-width; justification -controlled by \code{caption_just}. Overridden by \code{x$caption}.} +\code{NULL}, a single \code{NA} (treated as \code{NULL}), a single string, or a character +vector. Full-width; justification controlled by \code{caption_just}. Overridden +by \code{x$caption}.} \item{footnote}{Footnote text below the content. Accepts \code{NULL}, a single -string, or a character vector. Full-width; justification controlled by -\code{footnote_just}. Overridden by \code{x$footnote}.} +\code{NA} (treated as \code{NULL}), a single string, or a character vector. +Full-width; justification controlled by \code{footnote_just}. Overridden by +\code{x$footnote}.} \item{footer_left, footer_center, footer_right}{Footer text. Mirror of -header arguments. Vertically bottom-justified. Overridden by -\code{x$footer_left} etc.} +header arguments (a single \code{NA} is treated as \code{NULL}). Vertically +bottom-justified. Overridden by \code{x$footer_left} etc.} \item{gp}{Typography specification. Accepts either a single \code{gpar()} object applied to all text, or a named list for section- or element-level @@ -78,7 +82,7 @@ control. Resolution priority (highest first): element-level \item{header_rule}{Separator rule drawn between the header and the next section (caption or content), fitted within the \code{padding} gap. Accepts: \itemize{ -\item \code{FALSE}: no rule +\item \code{FALSE} (or a single \code{NA}): no rule \item \code{TRUE}: full-width rule \item A numeric in \verb{(0, 1]}: rule spanning that fraction of viewport width, centered @@ -124,8 +128,9 @@ single column — or any data column combined with the row-header group columns — wider than the page).} \item{page_i}{Integer page index, used to prefix layout error messages with -\code{"Page : "}. Set automatically by \code{\link[=export_tfl]{export_tfl()}}; -not normally supplied when calling this function directly.} +\code{"Page : "}. A single \code{NA} is treated the same as \code{NULL} (no prefix). +Set automatically by \code{\link[=export_tfl]{export_tfl()}}; not normally supplied when +calling this function directly.} \item{preview}{Logical. If \code{TRUE}, calls \code{grid.newpage()} and draws to the currently open device without opening or closing any device.} diff --git a/man/normalize_rule.Rd b/man/normalize_rule.Rd index a2a84dc..6a8a4ed 100644 --- a/man/normalize_rule.Rd +++ b/man/normalize_rule.Rd @@ -7,7 +7,9 @@ normalize_rule(x) } \arguments{ -\item{x}{FALSE, TRUE, numeric in (0,1], or a grob. +\item{x}{FALSE, TRUE, numeric in (0,1], or a grob. A single \code{NA} is treated +the same as \code{FALSE} (no rule), so that data-driven construction can leave +the toggle unset. A \code{linesGrob} is the typical choice, but any grob is accepted and will be drawn as-is (centered vertically in the padding gap).} } diff --git a/man/normalize_text.Rd b/man/normalize_text.Rd index 76fe68f..aa2265c 100644 --- a/man/normalize_text.Rd +++ b/man/normalize_text.Rd @@ -7,7 +7,8 @@ normalize_text(x) } \arguments{ -\item{x}{NULL, a single character string, or a character vector.} +\item{x}{\code{NULL}, a single \code{NA} (treated as \code{NULL}), a single character +string, or a character vector.} } \value{ A list with elements \code{text} (single string or NULL) and diff --git a/tests/testthat/test-export_tfl_page.R b/tests/testthat/test-export_tfl_page.R index 3a92254..861e7e0 100644 --- a/tests/testthat/test-export_tfl_page.R +++ b/tests/testthat/test-export_tfl_page.R @@ -5,6 +5,30 @@ library(ggplot2) +# Recursively collect every text-grob label from a captured grid gTree. +# Used to assert that a leaked literal "NA" annotation never reaches the page. +collect_text_labels <- function(g) { + labs <- character(0) + if (inherits(g, "text") && !is.null(g$label)) { + labs <- c(labs, as.character(g$label)) + } + if (!is.null(g$children)) { + for (nm in names(g$children)) { + labs <- c(labs, collect_text_labels(g$children[[nm]])) + } + } + labs +} + +# Render one page with character content (so the only text grobs are the +# body plus whichever annotations are present) and return all text labels. +labels_for_page <- function(...) { + grDevices::pdf(NULL, width = 11, height = 8.5) + on.exit(grDevices::dev.off(), add = TRUE) + export_tfl_page(list(content = "BODYTEXT"), ..., preview = TRUE) + collect_text_labels(grid::grid.grab()) +} + # --------------------------------------------------------------------------- # Input validation # --------------------------------------------------------------------------- @@ -145,6 +169,96 @@ test_that("export_tfl_page omits page prefix when page_i is NULL", { ) }) +# --------------------------------------------------------------------------- +# A single NA is treated the same as NULL (absent) — issue: data-driven pages +# --------------------------------------------------------------------------- + +test_that("labels_for_page sanity: a real annotation appears, NULL does not", { + # Positive control: a supplied header text is actually rendered. + expect_true("REALHEADER" %in% labels_for_page(header_left = "REALHEADER")) + # Negative control: omitting it produces no such label. + expect_false("REALHEADER" %in% labels_for_page()) +}) + +test_that("export_tfl_page treats a single NA annotation as absent (no 'NA' drawn)", { + text_args <- c("header_left", "header_center", "header_right", + "caption", "footnote", + "footer_left", "footer_center", "footer_right") + for (arg in text_args) { + labels <- do.call(labels_for_page, stats::setNames(list(NA), arg)) + expect_false("NA" %in% labels, + info = paste0(arg, " = NA leaked a literal 'NA' label")) + } +}) + +test_that("export_tfl_page: NA annotation renders identically to NULL", { + # All eight annotations NA should yield the same label set as none supplied. + all_na <- labels_for_page( + header_left = NA, header_center = NA, header_right = NA, + caption = NA, footnote = NA, + footer_left = NA, footer_center = NA, footer_right = NA + ) + none <- labels_for_page() + expect_setequal(all_na, none) + expect_equal(none, "BODYTEXT") +}) + +test_that("export_tfl_page: NA in x list override blanks a global annotation", { + # x list element (NA) takes precedence over the direct argument and, being a + # single NA, blanks the section rather than rendering "NA". + grDevices::pdf(NULL, width = 11, height = 8.5) + on.exit(grDevices::dev.off(), add = TRUE) + export_tfl_page( + list(content = "BODYTEXT", caption = NA), + caption = "GLOBALCAPTION", + preview = TRUE + ) + labs <- collect_text_labels(grid::grid.grab()) + expect_false("GLOBALCAPTION" %in% labs) + expect_false("NA" %in% labs) +}) + +test_that("export_tfl_page keeps NA embedded in a multi-line annotation", { + # Boundary: only a *single* NA is absent; a vector with NA among real values + # is collapsed with "\n" and drawn verbatim. + labels <- labels_for_page(caption = c("A", NA, "B")) + expect_true("A\nNA\nB" %in% labels) +}) + +test_that("export_tfl_page treats a single NA page_i the same as NULL", { + f <- tempfile(fileext = ".pdf") + grDevices::pdf(f, width = 4, height = 3) + on.exit({ + grDevices::dev.off() + unlink(f) + }) + + p <- ggplot(data.frame(x = 1, y = 1), aes(x, y)) + geom_point() + err <- tryCatch( + export_tfl_page(list(content = p), + caption = paste(rep("caption line", 30), collapse = "\n"), + footnote = paste(rep("footnote line", 30), collapse = "\n"), + page_i = NA + ), + error = function(e) conditionMessage(e) + ) + expect_match(err, "Content height") + expect_false(grepl("Page", err)) +}) + +test_that("export_tfl_page treats a single NA rule the same as FALSE", { + # NA rule must not error (it did before: fell through normalize_rule's abort). + grDevices::pdf(NULL, width = 11, height = 8.5) + on.exit(grDevices::dev.off(), add = TRUE) + expect_no_error( + export_tfl_page(list(content = "BODY"), + header_left = "H", footer_left = "F", + header_rule = NA, footer_rule = NA, + preview = TRUE + ) + ) +}) + # --------------------------------------------------------------------------- # Section presence logic — no optional sections # --------------------------------------------------------------------------- diff --git a/tests/testthat/test-normalize.R b/tests/testthat/test-normalize.R index 89b22bc..983f8b9 100644 --- a/tests/testthat/test-normalize.R +++ b/tests/testthat/test-normalize.R @@ -38,6 +38,48 @@ test_that("normalize_text handles character(0)", { expect_equal(result$nlines, 0L) }) +test_that("normalize_text treats a single NA the same as NULL", { + # Logical NA (the literal a user types as `caption = NA`) + result <- normalize_text(NA) + expect_null(result$text) + expect_equal(result$nlines, 0L) + + # NA of other atomic types behaves identically + for (na_val in list(NA_character_, NA_integer_, NA_real_)) { + r <- normalize_text(na_val) + expect_null(r$text) + expect_equal(r$nlines, 0L) + } +}) + +test_that("normalize_text keeps NA embedded in a longer vector (boundary)", { + # Only a *single* NA is absent; a vector with NA among real values is kept. + result <- normalize_text(c("a", NA, "b")) + expect_equal(result$text, "a\nNA\nb") + expect_equal(result$nlines, 3L) +}) + +# --------------------------------------------------------------------------- +# .is_single_na() +# --------------------------------------------------------------------------- + +test_that(".is_single_na is TRUE only for a length-1 atomic NA", { + expect_true(.is_single_na(NA)) + expect_true(.is_single_na(NA_character_)) + expect_true(.is_single_na(NA_integer_)) + expect_true(.is_single_na(NA_real_)) +}) + +test_that(".is_single_na is FALSE for NULL, real values, and multi-element NA", { + expect_false(.is_single_na(NULL)) + expect_false(.is_single_na("text")) + expect_false(.is_single_na(FALSE)) + expect_false(.is_single_na(character(0))) + expect_false(.is_single_na(c(NA, NA))) + expect_false(.is_single_na(c("a", NA))) + expect_false(.is_single_na(list(NA))) +}) + test_that("normalize_rule returns FALSE for FALSE", { expect_false(normalize_rule(FALSE)) }) @@ -62,6 +104,12 @@ test_that("normalize_rule errors for numeric outside (0, 1]", { expect_error(normalize_rule(-0.5)) }) +test_that("normalize_rule treats a single NA the same as FALSE", { + expect_false(normalize_rule(NA)) + expect_false(normalize_rule(NA_real_)) + expect_false(normalize_rule(NA_character_)) +}) + test_that("normalize_rule passes a linesGrob through unchanged", { lg <- grid::linesGrob() result <- normalize_rule(lg)