diff --git a/NAMESPACE b/NAMESPACE index 8e762535a..a0b87d21c 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -29,6 +29,8 @@ export(g_lineplot) export(g_lineplot_table) export(get_cox_pairwise_df) export(get_mmrm_results) +export(get_surv_diff_df) +export(get_surv_times_df) export(gg_km) export(gg_lineplot) export(gg_mmrm_lineplot) diff --git a/R/add_difference_row.R b/R/add_difference_row.R deleted file mode 100644 index 173ea0a10..000000000 --- a/R/add_difference_row.R +++ /dev/null @@ -1,180 +0,0 @@ -#' @describeIn tbl_survfit_times -#' -#' Adds survival differences between groups as additional rows to tables created by [`tbl_survfit_times()`]. -#' -#' Difference statistics are calculated using [cardx::ard_survival_survfit_diff()] -#' for all `tbl_survfit_times(times)` variable values, using `survfit` formula: -#' ```r -#' survival::survfit(y ~ by, data = data) -#' ``` -#' where `y`, `by` and `data` are the inputs of the same names to the `tbl_survfit_times()` object `x`. -#' -#' Pairwise differences are calculated relative to the specified `by` variable's specified reference level. -#' -#' @inheritParams gtsummary::add_difference.tbl_summary -#' @param reference (`string`)\cr -#' Value of the `tbl_survfit_times(by)` variable value that is the reference for -#' each of the difference calculations. For factors, use the character -#' level. The reference column will appear as the leftmost column in the table. -#' @param pvalue_fun (`function`)\cr -#' Function to round and format the `p.value` statistic. Default is [label_roche_pvalue()]. -#' The function must have a numeric vector input, and return a string that is the -#' rounded/formatted p-value (e.g. `pvalue_fun = label_style_pvalue(digits = 3)`). -#' -#' @export -#' @order 3 -#' -#' @examples -#' # Example 2 - Survival Differences ----------- -#' tbl_survfit_times( -#' data = cards::ADTTE, -#' by = "TRTA", -#' times = c(30, 60), -#' label = "Day {time}" -#' ) |> -#' add_difference_row(reference = "Placebo") -add_difference_row.tbl_survfit_times <- function(x, - reference, - statistic = c("{estimate}", "({conf.low}, {conf.high})", "{p.value}"), - conf.level = 0.95, - pvalue_fun = label_roche_pvalue(), - estimate_fun = label_roche_number(digits = 2, scale = 100), - ...) { - # check inputs --------------------------------------------------------------- - set_cli_abort_call() - check_dots_empty(call = get_cli_abort_call()) - check_not_missing(reference) - check_string(reference) - check_range(conf.level, range = c(0, 1)) - check_class(statistic, "character") - check_class(pvalue_fun, "function") - check_class(estimate_fun, "function") - - # check that input `x` has a `by` var with 2+ levels - if (is_empty(x$inputs$by)) { - cli::cli_abort( - "Cannot run {.fun add_difference_row} when {.code tbl_survfit_times()} does not include a {.arg by} argument.", - call = get_cli_abort_call() - ) - } - - lst_by_levels <- - x$table_styling$header |> - dplyr::filter(grepl(pattern = "^stat_\\d*[1-9]\\d*$", x = .data$column)) |> - dplyr::select("column", "modify_stat_level") |> - deframe() |> - lapply(FUN = as.character) - - # check reference level is appropriate - if (!as.character(reference) %in% unlist(lst_by_levels)) { - cli::cli_abort( - "The {.arg reference} argument must be one of {.val {unlist(lst_by_levels)}}.", - call = get_cli_abort_call() - ) - } - - # get function inputs -------------------------------------------------------- - func_inputs <- as.list(environment()) - by <- x$inputs$by - y <- x$inputs$y - times <- x$inputs$times - data <- x$inputs$data - form <- glue("{y} ~ {cardx::bt(by)}") |> stats::as.formula() - - # add reference level to the first position in factor - data[[by]] <- fct_relevel(data[[by]], reference, after = 0L) - ref_col <- names(lst_by_levels)[lst_by_levels == reference] - - # move reference column to first position in `x` - x <- x |> - gtsummary::modify_table_body( - ~ .x |> dplyr::relocate(all_of(ref_col), .after = "label") - ) - - # calculate survival difference ---------------------------------------------- - survfit_diff_ard_fun <- function(data, variable, ...) { - cardx::ard_survival_survfit_diff( - x = rlang::inject(survival::survfit(!!form, data = data)), - times = as.numeric(variable), - conf.level = conf.level - ) |> - dplyr::filter(!.data$stat_name %in% c("method", "reference_level")) - } - - # difference to be calculated for each time - for (t in times) data[[as.character(t)]] <- NA - - # create difference rows - tbl_surv_diff <- - data |> - # create dummy table to add difference rows to - gtsummary::tbl_summary(by = any_of(by), include = as.character(times), missing = "no") |> - gtsummary::add_difference_row( - reference = reference, - statistic = everything() ~ statistic, - test = everything() ~ survfit_diff_ard_fun, - conf.level = conf.level, - pvalue_fun = pvalue_fun, - estimate_fun = everything() ~ estimate_fun - ) - - # build gtsummary table ------------------------------------------------------ - tbl_surv_diff <- - tbl_surv_diff |> - # remove time labels - gtsummary::modify_table_body( - ~ .x |> - # remove dummy table label rows - dplyr::filter(row_type != "label") |> - dplyr::mutate( - # match variable names to `x` - variable = paste0("time", .data$variable), - # add default labels - label = dplyr::case_when( - .data$label == "Survival Difference" ~ "Difference in Event Free Rates", - .data$label == "(CI Lower Bound, CI Upper Bound)" ~ glue("{style_roche_number(conf.level, scale = 100)}% CI"), - .data$label == "p-value" ~ "p-value (Z-test)", - .default = .data$label - ) - ) - ) |> - # indent rows - gtsummary::modify_indent(columns = "label", rows = .data$row_type == "difference_row", indent = 8L) |> - gtsummary::modify_indent(columns = "label", rows = .data$label == "Difference in Event Free Rates", indent = 4L) - - # remove ARD for dummy table rows - tbl_surv_diff$cards$tbl_summary <- NULL - - # add difference rows into tbl_survfit_times table - x <- - gtsummary::tbl_stack( - tbls = list(x, tbl_surv_diff), - quiet = TRUE - ) |> - # move survival difference rows under each section for each matching survival time - gtsummary::modify_table_body( - \(x) { - x |> - dplyr::mutate( - variable_f = factor(gsub("-row_difference", "", .data$variable), levels = unique(x$variable)), - idx_row = dplyr::row_number() - ) |> - dplyr::arrange(dplyr::pick("variable_f", "idx_row")) |> - dplyr::select(-"variable_f", -"idx_row") - } - ) - - # add info to table ---------------------------------------------------------- - x$call_list <- list( - "tbl_survfit_times" = x$tbls[[1]]$call_list, - "add_difference_row" = match.call() - ) - x$cards <- lapply(x$tbls, \(x) x$cards) |> unlist(recursive = FALSE) - x$inputs <- list( - "tbl_survfit_times" = x$tbls[[1]]$inputs, - "add_difference_row" = func_inputs - ) - - x |> - structure(class = c("tbl_survfit_times", "gtsummary")) -} diff --git a/R/annotate_gg_km.R b/R/annotate_gg_km.R index ca4b56e16..6d09fddac 100644 --- a/R/annotate_gg_km.R +++ b/R/annotate_gg_km.R @@ -12,8 +12,11 @@ #' require a pure `ggplot2` object. #' @param fit_km (`survfit`)\cr #' A fitted Kaplan-Meier object of class `survfit` (from the `survival` -#' package). This object contains the necessary survival data used to -#' calculate and generate the content displayed in the annotation table. +#' package). This object contains the necessary survival data used by +#' `annotate_riskdf()` to calculate and generate the content displayed. +#' @param surv_tbl (`data.frame`)\cr +#' A data frame containing the pre-calculated survival summary results, +#' such as the output from `get_surv_times_df()`. #' @param coxph_tbl (`data.frame`)\cr #' A data frame containing the pre-calculated Cox-PH results, derived #' using function `get_cox_pairwise_df()`. @@ -63,25 +66,7 @@ #' @name annotate_gg_km NULL -#' @describeIn annotate_gg_km The function `annotate_riskdf` adds a "Numbers -#' at Risk" table below a Kaplan-Meier plot using `patchwork`.\cr -#' **Note:** For this specific function, `gg_plt` must be a pure `ggplot2` -#' object (not a combined `cowplot` object) because it requires exact X-axis -#' extraction. -#' -#' @return The function `annotate_riskdf` returns a `cowplot` object combining -#' the KM plot and the 'Numbers at Risk' table. -#' -#' @examples -#' # Annotate Plot with Numbers at Risk Table -#' annotate_riskdf(plt_kmg01, fit_kmg01) -#' -#' # Change order of y-axis (arm) -#' use_lung2 <- use_lung -#' use_lung2$arm <- factor(use_lung2$arm, levels = c("C", "B", "A")) -#' fit_kmg01 <- survival::survfit(formula, use_lung2) -#' annotate_riskdf(plt_kmg01, fit_kmg01) # rerun gg_km to change legend order -#' +#' @rdname annotate_gg_km #' @export annotate_riskdf <- function(gg_plt, fit_km, @@ -102,10 +87,6 @@ annotate_riskdf <- function(gg_plt, paste0( "`gg_plt` must be a pure ggplot object (not a cowplot object) for", "`annotate_riskdf`." - ), - "i" = paste0( - "cowplot objects are not supported because", - "exact X-axis extraction is required." ) ) } @@ -136,15 +117,8 @@ annotate_riskdf <- function(gg_plt, strata = strata_levels ) } else { - strata_lst <- strsplit( - sub("=", "equals", levels(annot_tbl$strata)), - "equals" - ) - levels(annot_tbl$strata) <- matrix( - unlist(strata_lst), - ncol = 2, - byrow = TRUE - )[, 2] + # Utilizing base R regex to safely strip variable names from strata levels + levels(annot_tbl$strata) <- sub("^[^=]+=", "", levels(annot_tbl$strata)) data.frame( n.risk = annot_tbl$n.risk, @@ -184,66 +158,26 @@ annotate_riskdf <- function(gg_plt, res } -#' @describeIn annotate_gg_km The `annotate_surv_med` function adds a -#' median survival time summary table as an annotation box. -#' -#' @return The function `annotate_surv_med` returns a `cowplot` object\cr -#' with the median survival table annotation added. -#' -#' @examples -#' # Annotate Kaplan-Meier Plot with Median Survival Table -#' annotate_surv_med(plt_kmg01, fit_kmg01) -#' -#' @export -annotate_surv_med <- function(gg_plt, - fit_km, - table_position = c( - x = 0.8, - y = 0.85, - w = 0.32, - h = 0.16 - ), - ...) { - set_cli_abort_call() - - default_eargs <- list( - font_size = 10, - fill = TRUE - ) - - eargs <- list(...) - eargs <- utils::modifyList(default_eargs, eargs) - - # Check explicitly allows cowplot objects for floating tables +#' Internal helper for floating table annotations +#' @keywords internal +#' @noRd +.add_floating_annotation <- function(gg_plt, tbl, table_position, eargs) { if (!inherits(gg_plt, c("gg", "ggplot", "cowplot"))) { rlang::abort("`gg_plt` must be a ggplot or cowplot object.") } - if (!inherits(fit_km, "survfit")) { - rlang::abort("`fit_km` must be a survfit object.") + if (!inherits(tbl, "data.frame")) { + rlang::abort("Input table must be a data.frame.") } - - strata_levels <- if (is.null(fit_km$strata)) "All" else levels(fit_km$strata) - - surv_med_tbl <- h_tbl_median_surv( - fit_km = fit_km, - strata_levels = strata_levels - ) - - if (!identical(rownames(surv_med_tbl), as.character(seq_len(nrow(surv_med_tbl))))) { - surv_med_tbl <- data.frame( - " " = rownames(surv_med_tbl), - surv_med_tbl, - check.names = FALSE - ) + if (!identical(rownames(tbl), as.character(seq_len(nrow(tbl))))) { + tbl <- data.frame(" " = rownames(tbl), tbl, check.names = FALSE) } bg_fill <- if (isTRUE(eargs[["fill"]])) "#00000020" else eargs[["fill"]] - # Call the floating table engine - res <- df2gg_floating( - df = surv_med_tbl, + df2gg_floating( + df = tbl, gg_plt = gg_plt, x = table_position["x"], y = table_position["y"], @@ -253,28 +187,24 @@ annotate_surv_med <- function(gg_plt, colwidths = NULL, bg_fill = bg_fill ) +} - res +#' @rdname annotate_gg_km +#' @export +annotate_surv_med <- function(gg_plt, + surv_tbl, + table_position = c( + x = 0.8, + y = 0.85, + w = 0.32, + h = 0.16 + ), + ...) { + eargs <- utils::modifyList(list(font_size = 10, fill = TRUE), list(...)) + .add_floating_annotation(gg_plt, surv_tbl, table_position, eargs) } -#' @describeIn annotate_gg_km The function `annotate_coxph()` adds a Cox -#' Proportional Hazards summary table as an annotation box. -#' -#' -#' @return The function `annotate_coxph` returns a `cowplot` object\cr -#' with the Cox-PH table annotation added. -#' -#' @examples -#' # Annotate Kaplan-Meier Plot with Cox-PH Table -#' coxph_tbl <- get_cox_pairwise_df( -#' formula, -#' data = use_lung, arm = "arm", ref_group = "A" -#' ) -#' result <- annotate_coxph(plt_kmg01, coxph_tbl) -#' -#' # Extract original plots from any annotated result -#' attr(result, "plotlist")$main -#' +#' @rdname annotate_gg_km #' @export annotate_coxph <- function(gg_plt, coxph_tbl, @@ -285,47 +215,6 @@ annotate_coxph <- function(gg_plt, h = 0.125 ), ...) { - set_cli_abort_call() - - default_eargs <- list( - fill = TRUE, - font_size = 10 - ) - - eargs <- list(...) - eargs <- utils::modifyList(default_eargs, eargs) - - # Check explicitly allows cowplot objects for floating tables - if (!inherits(gg_plt, c("gg", "ggplot", "cowplot"))) { - rlang::abort("`gg_plt` must be a ggplot or cowplot object.") - } - - if (!inherits(coxph_tbl, "data.frame")) { - rlang::abort("`coxph_tbl` must be a data.frame.") - } - - if (!identical(rownames(coxph_tbl), as.character(seq_len(nrow(coxph_tbl))))) { - coxph_tbl <- data.frame( - " " = rownames(coxph_tbl), - coxph_tbl, - check.names = FALSE - ) - } - - bg_fill <- if (isTRUE(eargs[["fill"]])) "#00000020" else eargs[["fill"]] - - # Call the floating table engine - res <- df2gg_floating( - df = coxph_tbl, - gg_plt = gg_plt, - x = table_position["x"], - y = table_position["y"], - w = table_position["w"], - h = table_position["h"], - font_size = eargs[["font_size"]], - colwidths = NULL, - bg_fill = bg_fill - ) - - res + eargs <- utils::modifyList(list(font_size = 10, fill = TRUE), list(...)) + .add_floating_annotation(gg_plt, coxph_tbl, table_position, eargs) } diff --git a/R/deprecated.R b/R/deprecated.R index 3e73db427..ed756b7be 100644 --- a/R/deprecated.R +++ b/R/deprecated.R @@ -53,3 +53,37 @@ preprocess_lineplot_data <- function(...) { "crane::annotate_gg()" ) } + +#' @rdname deprecated +#' @export +add_overall.tbl_survfit_times <- function(x, ...) { + lifecycle::deprecate_stop( + when = "0.3.4", # Replace with the package version where this became defunct + what = "add_overall()", + details = paste( + "Since we decoupled the architecture, a user wanting an overall column", + "alongside stratified columns should simply fit an unstratified model,", + "extract the dataframe, and use standard `dplyr::bind_rows()` with", + "their stratified dataframe before pushing the combined output into", + "`tbl_survfit_times()`." + ) + ) +} + +#' @rdname deprecated +#' @export +add_difference_row.tbl_survfit_times <- function(x, ...) { + lifecycle::deprecate_stop( + when = "0.3.4", # Replace with the package version where this became defunct + what = "add_difference_row()", + with = "get_surv_diff_df()", + details = paste( + "`add_difference_row()` is defunct for `tbl_survfit_times`.", + "Since the architecture was decoupled, the table object no longer", + "contains the raw data required to calculate survival differences.", + "Please use the new `get_surv_diff_df()` function to extract difference", + "statistics, bind those rows to your survival dataframe using", + "`dplyr::bind_rows()`, and then render the final `tbl_survfit_times()`." + ) + ) +} diff --git a/R/get_surv_diff_df.R b/R/get_surv_diff_df.R new file mode 100644 index 000000000..59b43c9c3 --- /dev/null +++ b/R/get_surv_diff_df.R @@ -0,0 +1,105 @@ +#' Extract Survival Differences at Specific Times +#' +#' @description +#' Calculates survival differences, confidence intervals, and p-values at +#' specified time points using `cardx::ard_survival_survfit_diff()`. The +#' resulting `data.frame` is structurally identical to the output of +#' `get_surv_times_df()`, allowing them to be easily bound together before +#' passing into `tbl_survfit_times()`. +#' +#' @param fit_km (`survfit`)\cr +#' A fitted Kaplan-Meier object of class `survfit`. +#' @param times (`numeric`)\cr +#' A numeric vector of time points at which to evaluate differences. +#' @param reference (`character`)\cr +#' The name of the reference strata level to calculate differences against. +#' @param conf_int (`numeric`)\cr +#' The confidence level to use for the intervals. Defaults to `0.95`. +#' @param scale (`numeric`)\cr +#' A scaling factor for the survival estimates. For example, `100` converts +#' probabilities to percentages. Defaults to `1`. +#' +#' @return A `data.frame` with columns for `Strata`, `Time`, `Survival`, +#' `XX% CI`, and `p-value`. +#' +#' @examples +#' library(survival) +#' surv_data <- lung +#' surv_data$status <- surv_data$status - 1 +#' surv_data$sex <- factor(surv_data$sex, labels = c("Male", "Female")) +#' +#' # 1. Fit the model +#' fit_km <- survfit(Surv(time, status) ~ sex, data = surv_data) +#' +#' # 2. Extract standard survival times +#' df_surv <- get_surv_times_df(fit_km, times = c(100, 200)) +#' +#' # 3. Extract survival differences (Female vs Male) +#' df_diff <- get_surv_diff_df( +#' fit_km, +#' times = c(100, 200), +#' reference = "Male" +#' ) +#' +#' # 4. Combine and render +#' combined_df <- dplyr::bind_rows(df_surv, df_diff) +#' tbl_survfit_times(combined_df) +#' +#' @export +get_surv_diff_df <- function(fit_km, times, reference, conf_int = 0.95, + scale = 1) { + if (!inherits(fit_km, "survfit")) { + rlang::abort("`fit_km` must be a survfit object.") + } + + if (!is.numeric(times) || length(times) == 0) { + rlang::abort("`times` must be a non-empty numeric vector.") + } + + # Calculate differences using cardx's Analysis Results Data (ARD) framework + ard_res <- cardx::ard_survival_survfit_diff( + x = fit_km, + times = times, + conf.level = conf_int + ) + + # Extract the underlying list data into a flat format. + # Note: cardx stores the numeric time points in `variable_level`. + flat_df <- ard_res |> + dplyr::filter(!.data$stat_name %in% c("method", "reference_level")) |> + dplyr::mutate( + time = as.numeric(unlist(.data$variable_level)), + comp_level = as.character(unlist(.data$group1_level)), + value = as.numeric(unlist(.data$stat)) + ) |> + dplyr::select("time", "comp_level", "stat_name", "value") + + # Pivot wider to build standard columns matching get_surv_times_df() + wide_df <- flat_df |> + tidyr::pivot_wider( + names_from = "stat_name", + values_from = "value" + ) + + ci_col_name <- paste0(conf_int * 100, "% CI") + + # Construct the final data.frame, ensuring the strata name clearly denotes + # that this row represents a calculated difference versus the reference. + res_df <- data.frame( + Strata = paste(wide_df$comp_level, "vs", reference, "(Diff)"), + Time = wide_df$time, + `N at Risk` = NA_real_, + Survival = sprintf("%.2f", wide_df$estimate * scale), + CI = paste0( + "(", sprintf("%.2f", wide_df$conf.low * scale), + ", ", sprintf("%.2f", wide_df$conf.high * scale), ")" + ), + `p-value` = sprintf("%.3f", wide_df$p.value), + stringsAsFactors = FALSE, + check.names = FALSE + ) + + names(res_df)[names(res_df) == "CI"] <- ci_col_name + + res_df +} diff --git a/R/get_surv_times_df.R b/R/get_surv_times_df.R new file mode 100644 index 000000000..6c74f4047 --- /dev/null +++ b/R/get_surv_times_df.R @@ -0,0 +1,85 @@ +#' Generate Table of Survival Estimates at Specific Times +#' +#' @description +#' This function extracts survival probabilities, confidence intervals, and +#' numbers at risk from a `survfit` object at specified time points. It returns +#' a formatted `data.frame` that can be manipulated before passing to +#' `tbl_surv_times()`. +#' +#' @param fit_km (`survfit`)\cr +#' A fitted Kaplan-Meier object of class `survfit`. +#' @param times (`numeric`)\cr +#' A numeric vector of time points at which to evaluate survival estimates. +#' @param conf_int (`numeric`)\cr +#' The confidence level to use for the intervals. Defaults to `0.95`. +#' @param scale (`numeric`)\cr +#' A scaling factor for the survival estimates. For example, `100` converts +#' probabilities to percentages. Defaults to `1`. +#' +#' @return A `data.frame` with columns for `Strata`, `Time`, `N at Risk`, +#' `Survival`, and `XX% CI`. +#' +#' @examples +#' library(survival) +#' surv_data <- lung +#' surv_data$status <- surv_data$status - 1 +#' +#' # Example: Handling advanced arguments natively in survfit. +#' # Instead of passing `method.args`, apply them directly to survfit(). +#' # Here we set a 99% CI and specify a cluster ID (inst). +#' fit_complex <- survfit( +#' Surv(time, status) ~ sex, +#' data = surv_data, +#' id = inst, +#' conf.int = 0.99 +#' ) +#' +#' get_surv_times_df(fit_complex, times = c(100, 200), conf_int = 0.99) +#' +#' @export +get_surv_times_df <- function(fit_km, times, conf_int = 0.95, scale = 1) { + # Enforce rigorous type-checking using rlang + if (!inherits(fit_km, "survfit")) { + rlang::abort("`fit_km` must be a survfit object.") + } + + if (!is.numeric(times) || length(times) == 0) { + rlang::abort("`times` must be a non-empty numeric vector.") + } + + # Extract summary at specific times, extending to last known survival + # handles times beyond the max observed time gracefully. + summ <- summary(fit_km, times = times, extend = TRUE) + + strata_levels <- if (!is.null(summ$strata)) { + as.character(summ$strata) + } else { + "All" + } + + # Remove variable prefix from strata to maintain clean downstream headers + # without cluttering tables with 'arm=A', 'arm=B' etc. + if (!is.null(fit_km$strata)) { + strata_levels <- sub("^[^=]+=", "", strata_levels) + } + + df <- data.frame( + Strata = strata_levels, + Time = summ$time, + `N at Risk` = summ$n.risk, + Survival = sprintf("%.2f", summ$surv * scale), + CI = paste0( + "(", sprintf("%.2f", summ$lower * scale), + ", ", sprintf("%.2f", summ$upper * scale), ")" + ), + stringsAsFactors = FALSE, + check.names = FALSE + ) + + # Rename the general 'CI' column to reflect the exact interval used + # giving users an accurate header prior to any manual renaming steps + ci_col_name <- paste0(conf_int * 100, "% CI") + names(df)[names(df) == "CI"] <- ci_col_name + + df +} diff --git a/R/tbl_survfit_times.R b/R/tbl_survfit_times.R index 8725f6986..a7c68cefd 100644 --- a/R/tbl_survfit_times.R +++ b/R/tbl_survfit_times.R @@ -1,169 +1,135 @@ -#' Survival Times +#' Create a gtsummary Table of Survival Times #' -#' Create a gtsummary table with Kaplan-Meier estimated survival estimates -#' and specified times. +#' @description +#' Generates a `gtsummary` table from the survival times `data.frame` created by +#' `get_surv_times_df()`. The table dynamically adapts to whichever columns the +#' user leaves in the data frame, allowing for easy customization. #' -#' @inheritParams tbl_survfit_quantiles -#' @inheritParams cardx::ard_survival_survfit -#' @inheritParams gtsummary::add_overall.tbl_summary -#' @param label (`string`)\cr -#' Label to appear in the header row. Default is `"Time {time}"`, where -#' the glue syntax injects the time estimate into the label. -#' @param statistic (`character`)\cr -#' Character vector of the statistics to report. -#' May use any of the following statistics: -#' `c(n.risk, estimate, std.error, conf.low, conf.high)`, -#' Default is `c("{n.risk}", "{estimate}", "({conf.low}, {conf.high})")` +#' @param surv_df (`data.frame`)\cr +#' The results `data.frame` generated by `get_surv_times_df()`. #' -#' Statistics available to include when using `add_difference_row()` are: -#' `"estimate"`, `"std.error"`, `"statistic"`, `"conf.low"`, `"conf.high"`, `"p.value"`. -#' @param estimate_fun (`function`) \cr -#' Function used to style/round the `c(estimate, conf.low, conf.high)` statistics. -#' @param x (`tbl_survfit_times`)\cr -#' A stratified 'tbl_survfit_times' object +#' @return A `gtsummary` object. #' -#' @details -#' When the `statistic` argument is modified, the statistic labels will likely -#' also need to be updated. To change the label, call the `modify_table_body()` -#' function to directly update the underlying `x$table_body` data frame. +#' @examples +#' library(survival) +#' surv_data <- lung +#' surv_data$status <- surv_data$status - 1 #' -#' @returns a gtsummary table -#' @name tbl_survfit_times -#' @order 1 +#' # Example: Replicating "add_overall" functionality. +#' # 1. Fit the stratified model and extract data +#' fit_strat <- survfit(Surv(time, status) ~ sex, data = surv_data) +#' df_strat <- get_surv_times_df(fit_strat, times = c(100, 200)) +#' +#' # 2. Fit the overall unstratified model and extract data +#' fit_overall <- survfit(Surv(time, status) ~ 1, data = surv_data) +#' df_overall <- get_surv_times_df(fit_overall, times = c(100, 200)) +#' +#' # 3. Rename the unstratified label to "Overall" (instead of "All") +#' df_overall$Strata <- "Overall" +#' +#' # 4. Combine rows and render the single stacked table +#' combined_df <- rbind(df_overall, df_strat) +#' tbl_survfit_times(combined_df) #' -#' @examples -#' # Example 1 ---------------------------------- -#' tbl_survfit_times( -#' data = cards::ADTTE, -#' by = "TRTA", -#' times = c(30, 60), -#' label = "Day {time}" -#' ) |> -#' add_overall() -NULL - -#' @rdname tbl_survfit_times #' @export -#' @order 2 -tbl_survfit_times <- function(data, - times, - y = "survival::Surv(time = AVAL, event = 1 - CNSR, type = 'right', origin = 0)", - by = NULL, - label = "Time {time}", - statistic = c("{n.risk}", "{estimate}", "({conf.low}, {conf.high})"), - estimate_fun = label_roche_number(digits = 1, scale = 100), - method.args = list(conf.int = 0.95, conf.type = "plain")) { - # check inputs --------------------------------------------------------------- - method.args <- enquo(method.args) - set_cli_abort_call() - check_not_missing(data) - check_not_missing(times) - check_data_frame(data) - check_numeric(times) - check_range(times, range = c(0, Inf)) - check_string(label) - check_class(statistic, "character") - check_class(estimate_fun, "function") - cards::process_selectors(data, by = {{ by }}) - check_scalar(by, allow_empty = TRUE) - if ("time" %in% by) { - cli::cli_abort( - "The {.arg by} column cannot be named {.val time}.", - call = get_cli_abort_call() - ) +tbl_survfit_times <- function(surv_df) { + if (!is.data.frame(surv_df)) { + rlang::abort("`surv_df` must be a data.frame.") } - y <- .expr_as_string({{ y }}) # convert y to string (if not already) - func_inputs <- as.list(environment()) - - # subset data on complete row ------------------------------------------------ - form <- glue("{y} ~ {ifelse(is_empty(by), 1, cardx::bt(by))}") |> stats::as.formula() - data <- data[stats::complete.cases(data[all.vars(form)]), ] - - # get survival quantiles ----------------------------------------------------- - ard_surv_times <- - cardx::ard_survival_survfit( - x = data, - y = y, - variables = any_of(by), - times = times, - method.args = !!method.args - ) |> - cards::update_ard_fmt_fun( - stat_names = c("estimate", "conf.low", "conf.high"), - fmt_fun = estimate_fun - ) |> - cards::update_ard_fmt_fun( - stat_names = c("n.risk", "n.censor", "cum.risk", "cum.censor"), - fmt_fun = label_roche_number() + if (!"Strata" %in% names(surv_df) || !"Time" %in% names(surv_df)) { + rlang::abort("`surv_df` must contain at least 'Strata' and 'Time' columns.") + } + + ci_col <- grep("% CI", names(surv_df), value = TRUE) + has_ci <- length(ci_col) > 0 + + unique_strata <- unique(surv_df$Strata) + + if (length(unique_strata) == 1) { + res <- .get_single_time_table( + surv_df, + ci_col = if (has_ci) ci_col[1] else NULL ) + } else { + res <- surv_df |> + gtsummary::tbl_strata( + strata = "Strata", + .combine_with = "tbl_stack", + .header = "{strata}", + .tbl_fun = function(x) { + .get_single_time_table( + x, + ci_col = if (has_ci) ci_col[1] else NULL + ) + } + ) |> + gtsummary::modify_header(groupname_col = " ") + } + + res <- res |> gtsummary::modify_header(label = "Time") + + # Ensure the object receives the correct class for S3 dispatch + class(res) <- c("tbl_survfit_times", class(res)) + + res +} - # calculate ARD for by vars - if (!is_empty(by)) { - ard_by <- cards::ard_tabulate(data, variables = all_of(by)) +#' Build Single Strata Table for Survival Times +#' @keywords internal +#' @noRd +.get_single_time_table <- function(data_subset, ci_col = NULL) { + data_subset$Time <- paste("Day", data_subset$Time) + + vars_include <- c() + label_list <- list() + + if ("N at Risk" %in% names(data_subset)) { + vars_include <- c(vars_include, "N at Risk") + label_list <- c(label_list, list(`N at Risk` ~ "N at Risk")) } - ard_n <- cards::ard_total_n(data) - - # get the confidence level - conf.level <- - ard_surv_times |> - dplyr::filter(.data$stat_name == "conf.level") |> - dplyr::pull("stat") |> - unlist() - - # build gtsummary table ------------------------------------------------------ - tbl <- - dplyr::bind_rows( - ard_surv_times |> - # remove model-wide stats - dplyr::filter(.data$variable == "time") |> - dplyr::mutate( - variable = paste0(.data$variable, unlist(.data$variable_level)), - variable_level = NULL - ), - case_switch(!is_empty(by) ~ ard_by), - ard_n + + if ("Survival" %in% names(data_subset)) { + vars_include <- c(vars_include, "Survival") + label_list <- c(label_list, list(Survival ~ "Survival")) + } + + if (!is.null(ci_col) && ci_col %in% names(data_subset)) { + vars_include <- c(vars_include, ci_col) + label_list <- c(label_list, stats::setNames(list(ci_col), ci_col)) + } + + # Capture the p-value column generated by difference tables + if ("p-value" %in% names(data_subset)) { + vars_include <- c(vars_include, "p-value") + label_list <- c(label_list, list(`p-value` ~ "p-value")) + } + + res <- data_subset |> + cards::ard_mvsummary( + variables = dplyr::all_of(vars_include), + by = "Time", + statistic = ~ list(my_stat = \(x, ...) x[1]) ) |> gtsummary::tbl_ard_summary( - by = any_of(by), - type = starts_with("time") ~ "continuous2", - statistic = starts_with("time") ~ statistic, - label = - map(times, ~ glue::glue_data(list(time = .x), label)) |> - set_names(paste0("time", times)) + by = "Time", + type = list(gtsummary::everything() ~ "continuous"), + statistic = list(gtsummary::everything() ~ "{my_stat}"), + label = label_list, + missing = "no" ) |> - gtsummary::modify_header( - gtsummary::all_stat_cols() ~ "{level} \n(N = {n})", - label = "" - ) |> - gtsummary::modify_table_body( - ~ .x |> - dplyr::mutate( - label = dplyr::case_when( - .data$label == "Number of Subjects at Risk" ~ "Patients remaining at risk", - .data$label == "Survival Probability" ~ "Event Free Rate (%)", - .data$label == "(CI Lower Bound, CI Upper Bound)" ~ glue("{style_roche_number(conf.level, scale = 100)}% CI"), - .default = .data$label - ) - ) - ) + gtsummary::modify_header(gtsummary::all_stat_cols() ~ " ") |> + gtsummary::modify_footnote(gtsummary::everything() ~ NA) - # return tbl ----------------------------------------------------------------- - tbl$cards <- - list( - tbl_survfit_times = - dplyr::bind_rows( - ard_surv_times, - if (!is_empty(by)) ard_by, # styler: off - ard_n - ) - ) - tbl[["call_list"]] <- list(tbl_survfit_times = match.call()) - tbl$inputs <- func_inputs - tbl |> - structure(class = c("tbl_survfit_times", "gtsummary")) -} + # Indent the confidence interval row for better readability + if (!is.null(ci_col)) { + res <- res |> + gtsummary::modify_indent( + columns = "label", + rows = .data$variable == ci_col, + indent = 4L + ) + } -#' @export -#' @rdname tbl_survfit_times -add_overall.tbl_survfit_times <- add_overall.tbl_survfit_quantiles + res +} diff --git a/_pkgdown.yml b/_pkgdown.yml index caa3260f2..4b3ae0fac 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -46,6 +46,8 @@ reference: - gg_km - annotate_gg_km - get_cox_pairwise_df + - get_surv_times_df + - get_surv_diff_df - title: "Forest Plot" contents: diff --git a/man/annotate_gg_km.Rd b/man/annotate_gg_km.Rd index a7bd7b47c..4b90f34e0 100644 --- a/man/annotate_gg_km.Rd +++ b/man/annotate_gg_km.Rd @@ -18,7 +18,7 @@ annotate_riskdf( annotate_surv_med( gg_plt, - fit_km, + surv_tbl, table_position = c(x = 0.8, y = 0.85, w = 0.32, h = 0.16), ... ) @@ -38,8 +38,8 @@ require a pure \code{ggplot2} object.} \item{fit_km}{(\code{survfit})\cr A fitted Kaplan-Meier object of class \code{survfit} (from the \code{survival} -package). This object contains the necessary survival data used to -calculate and generate the content displayed in the annotation table.} +package). This object contains the necessary survival data used by +\code{annotate_riskdf()} to calculate and generate the content displayed.} \item{title}{(\code{character})\cr A single string value indicating whether to include a title above the @@ -65,6 +65,10 @@ have a background fill. Default is \code{TRUE}. inside the annotation box. Default is \code{10}. }} +\item{surv_tbl}{(\code{data.frame})\cr +A data frame containing the pre-calculated survival summary results, +such as the output from \code{get_surv_times_df()}.} + \item{table_position}{(\code{numeric})\cr A named numeric vector \code{c(x, y, w, h)} defining the position and size of the floating table. \code{x} and \code{y} are the coordinates (0 to 1), @@ -75,37 +79,12 @@ by function.} A data frame containing the pre-calculated Cox-PH results, derived using function \code{get_cox_pairwise_df()}.} } -\value{ -The function \code{annotate_riskdf} returns a \code{cowplot} object combining -the KM plot and the 'Numbers at Risk' table. - -The function \code{annotate_surv_med} returns a \code{cowplot} object\cr -with the median survival table annotation added. - -The function \code{annotate_coxph} returns a \code{cowplot} object\cr -with the Cox-PH table annotation added. -} \description{ These functions provide capabilities to annotate Kaplan-Meier plots (\code{\link[=gg_km]{gg_km()}}) with additional summary tables, including median survival times, numbers at risk, and cox proportional hazards results. The annotations are added using the \code{cowplot} package for flexible placement. } -\section{Functions}{ -\itemize{ -\item \code{annotate_riskdf()}: The function \code{annotate_riskdf} adds a "Numbers -at Risk" table below a Kaplan-Meier plot using \code{patchwork}.\cr -\strong{Note:} For this specific function, \code{gg_plt} must be a pure \code{ggplot2} -object (not a combined \code{cowplot} object) because it requires exact X-axis -extraction. - -\item \code{annotate_surv_med()}: The \code{annotate_surv_med} function adds a -median survival time summary table as an annotation box. - -\item \code{annotate_coxph()}: The function \code{annotate_coxph()} adds a Cox -Proportional Hazards summary table as an annotation box. - -}} \examples{ \dontshow{if (requireNamespace("survival", quietly = TRUE)) withAutoprint(\{ # examplesIf} # Preparing the Kaplan-Meier Plot @@ -121,28 +100,6 @@ surv_plot_data <- process_survfit(fit_kmg01) plt_kmg01 <- gg_km(surv_plot_data) \dontshow{\}) # examplesIf} -# Annotate Plot with Numbers at Risk Table -annotate_riskdf(plt_kmg01, fit_kmg01) - -# Change order of y-axis (arm) -use_lung2 <- use_lung -use_lung2$arm <- factor(use_lung2$arm, levels = c("C", "B", "A")) -fit_kmg01 <- survival::survfit(formula, use_lung2) -annotate_riskdf(plt_kmg01, fit_kmg01) # rerun gg_km to change legend order - -# Annotate Kaplan-Meier Plot with Median Survival Table -annotate_surv_med(plt_kmg01, fit_kmg01) - -# Annotate Kaplan-Meier Plot with Cox-PH Table -coxph_tbl <- get_cox_pairwise_df( - formula, - data = use_lung, arm = "arm", ref_group = "A" -) -result <- annotate_coxph(plt_kmg01, coxph_tbl) - -# Extract original plots from any annotated result -attr(result, "plotlist")$main - } \seealso{ \code{\link[=gg_km]{gg_km()}}, \code{\link[=process_survfit]{process_survfit()}}, and \code{\link[=get_cox_pairwise_df]{get_cox_pairwise_df()}} for diff --git a/man/deprecated.Rd b/man/deprecated.Rd index 5a1d0f660..b79daef25 100644 --- a/man/deprecated.Rd +++ b/man/deprecated.Rd @@ -6,6 +6,8 @@ \alias{g_lineplot} \alias{g_lineplot_table} \alias{preprocess_lineplot_data} +\alias{add_overall.tbl_survfit_times} +\alias{add_difference_row.tbl_survfit_times} \title{Deprecated functions} \usage{ tbl_demographics(..., nonmissing = "always") @@ -15,6 +17,10 @@ g_lineplot(...) g_lineplot_table(...) preprocess_lineplot_data(...) + +\method{add_overall}{tbl_survfit_times}(x, ...) + +\method{add_difference_row}{tbl_survfit_times}(x, ...) } \value{ Warnings diff --git a/man/get_surv_diff_df.Rd b/man/get_surv_diff_df.Rd new file mode 100644 index 000000000..97e1fba80 --- /dev/null +++ b/man/get_surv_diff_df.Rd @@ -0,0 +1,60 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/get_surv_diff_df.R +\name{get_surv_diff_df} +\alias{get_surv_diff_df} +\title{Extract Survival Differences at Specific Times} +\usage{ +get_surv_diff_df(fit_km, times, reference, conf_int = 0.95, scale = 1) +} +\arguments{ +\item{fit_km}{(\code{survfit})\cr +A fitted Kaplan-Meier object of class \code{survfit}.} + +\item{times}{(\code{numeric})\cr +A numeric vector of time points at which to evaluate differences.} + +\item{reference}{(\code{character})\cr +The name of the reference strata level to calculate differences against.} + +\item{conf_int}{(\code{numeric})\cr +The confidence level to use for the intervals. Defaults to \code{0.95}.} + +\item{scale}{(\code{numeric})\cr +A scaling factor for the survival estimates. For example, \code{100} converts +probabilities to percentages. Defaults to \code{1}.} +} +\value{ +A \code{data.frame} with columns for \code{Strata}, \code{Time}, \code{Survival}, +\verb{XX\% CI}, and \code{p-value}. +} +\description{ +Calculates survival differences, confidence intervals, and p-values at +specified time points using \code{cardx::ard_survival_survfit_diff()}. The +resulting \code{data.frame} is structurally identical to the output of +\code{get_surv_times_df()}, allowing them to be easily bound together before +passing into \code{tbl_survfit_times()}. +} +\examples{ +library(survival) +surv_data <- lung +surv_data$status <- surv_data$status - 1 +surv_data$sex <- factor(surv_data$sex, labels = c("Male", "Female")) + +# 1. Fit the model +fit_km <- survfit(Surv(time, status) ~ sex, data = surv_data) + +# 2. Extract standard survival times +df_surv <- get_surv_times_df(fit_km, times = c(100, 200)) + +# 3. Extract survival differences (Female vs Male) +df_diff <- get_surv_diff_df( + fit_km, + times = c(100, 200), + reference = "Male" +) + +# 4. Combine and render +combined_df <- dplyr::bind_rows(df_surv, df_diff) +tbl_survfit_times(combined_df) + +} diff --git a/man/get_surv_times_df.Rd b/man/get_surv_times_df.Rd new file mode 100644 index 000000000..80a8c91ae --- /dev/null +++ b/man/get_surv_times_df.Rd @@ -0,0 +1,50 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/get_surv_times_df.R +\name{get_surv_times_df} +\alias{get_surv_times_df} +\title{Generate Table of Survival Estimates at Specific Times} +\usage{ +get_surv_times_df(fit_km, times, conf_int = 0.95, scale = 1) +} +\arguments{ +\item{fit_km}{(\code{survfit})\cr +A fitted Kaplan-Meier object of class \code{survfit}.} + +\item{times}{(\code{numeric})\cr +A numeric vector of time points at which to evaluate survival estimates.} + +\item{conf_int}{(\code{numeric})\cr +The confidence level to use for the intervals. Defaults to \code{0.95}.} + +\item{scale}{(\code{numeric})\cr +A scaling factor for the survival estimates. For example, \code{100} converts +probabilities to percentages. Defaults to \code{1}.} +} +\value{ +A \code{data.frame} with columns for \code{Strata}, \code{Time}, \verb{N at Risk}, +\code{Survival}, and \verb{XX\% CI}. +} +\description{ +This function extracts survival probabilities, confidence intervals, and +numbers at risk from a \code{survfit} object at specified time points. It returns +a formatted \code{data.frame} that can be manipulated before passing to +\code{tbl_surv_times()}. +} +\examples{ +library(survival) +surv_data <- lung +surv_data$status <- surv_data$status - 1 + +# Example: Handling advanced arguments natively in survfit. +# Instead of passing `method.args`, apply them directly to survfit(). +# Here we set a 99\% CI and specify a cluster ID (inst). +fit_complex <- survfit( + Surv(time, status) ~ sex, + data = surv_data, + id = inst, + conf.int = 0.99 +) + +get_surv_times_df(fit_complex, times = c(100, 200), conf_int = 0.99) + +} diff --git a/man/tbl_survfit_times.Rd b/man/tbl_survfit_times.Rd index c172dcf30..f29094fdc 100644 --- a/man/tbl_survfit_times.Rd +++ b/man/tbl_survfit_times.Rd @@ -1,146 +1,42 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/tbl_survfit_times.R, R/add_difference_row.R +% Please edit documentation in R/tbl_survfit_times.R \name{tbl_survfit_times} \alias{tbl_survfit_times} -\alias{add_difference_row.tbl_survfit_times} -\alias{add_overall.tbl_survfit_times} -\title{Survival Times} +\title{Create a gtsummary Table of Survival Times} \usage{ -tbl_survfit_times( - data, - times, - y = "survival::Surv(time = AVAL, event = 1 - CNSR, type = 'right', origin = 0)", - by = NULL, - label = "Time {time}", - statistic = c("{n.risk}", "{estimate}", "({conf.low}, {conf.high})"), - estimate_fun = label_roche_number(digits = 1, scale = 100), - method.args = list(conf.int = 0.95, conf.type = "plain") -) - -\method{add_difference_row}{tbl_survfit_times}( - x, - reference, - statistic = c("{estimate}", "({conf.low}, {conf.high})", "{p.value}"), - conf.level = 0.95, - pvalue_fun = label_roche_pvalue(), - estimate_fun = label_roche_number(digits = 2, scale = 100), - ... -) - -\method{add_overall}{tbl_survfit_times}( - x, - last = FALSE, - col_label = "All Participants \\nN = {style_roche_number(N)}", - ... -) +tbl_survfit_times(surv_df) } \arguments{ -\item{data}{(\code{data.frame})\cr -A data frame} - -\item{times}{(\code{numeric})\cr -a vector of times for which to return survival probabilities.} - -\item{y}{(\code{string} or \code{expression})\cr -A string or expression with the survival outcome, e.g. \code{survival::Surv(time, status)}. -The default value is -\code{survival::Surv(time = AVAL, event = 1 - CNSR, type = "right", origin = 0)}.} - -\item{by}{(\code{\link[dplyr:dplyr_tidy_select]{tidy-select}})\cr -A single column from \code{data}. Summary statistics will be stratified by this variable. -Default is \code{NULL}, which returns results for the unstratified model.} - -\item{label}{(\code{string})\cr -Label to appear in the header row. Default is \code{"Time {time}"}, where -the glue syntax injects the time estimate into the label.} - -\item{statistic}{(\code{character})\cr -Character vector of the statistics to report. -May use any of the following statistics: -\code{c(n.risk, estimate, std.error, conf.low, conf.high)}, -Default is \code{c("{n.risk}", "{estimate}", "({conf.low}, {conf.high})")} - -Statistics available to include when using \code{add_difference_row()} are: -\code{"estimate"}, \code{"std.error"}, \code{"statistic"}, \code{"conf.low"}, \code{"conf.high"}, \code{"p.value"}.} - -\item{estimate_fun}{(\code{function}) \cr -Function used to style/round the \code{c(estimate, conf.low, conf.high)} statistics.} - -\item{method.args}{(named \code{list})\cr -Named list of arguments that will be passed to \code{survival::survfit()}. - -Note that this list may contain non-standard evaluation components, and -must be handled similarly to tidyselect inputs by using -rlang's embrace operator \code{{{ . }}} or \code{!!enquo()} when programming with this -function.} - -\item{x}{(\code{tbl_survfit_times})\cr -A stratified 'tbl_survfit_times' object} - -\item{reference}{(\code{string})\cr -Value of the \code{tbl_survfit_times(by)} variable value that is the reference for -each of the difference calculations. For factors, use the character -level. The reference column will appear as the leftmost column in the table.} - -\item{conf.level}{(\code{numeric})\cr -a scalar in the interval \verb{(0, 1)} indicating the confidence level. Default is 0.95} - -\item{pvalue_fun}{(\code{function})\cr -Function to round and format the \code{p.value} statistic. Default is \code{\link[=label_roche_pvalue]{label_roche_pvalue()}}. -The function must have a numeric vector input, and return a string that is the -rounded/formatted p-value (e.g. \code{pvalue_fun = label_style_pvalue(digits = 3)}).} - -\item{...}{These dots are for future extensions and must be empty.} - -\item{last}{(scalar \code{logical})\cr -Logical indicator to display overall column last in table. -Default is \code{FALSE}, which will display overall column first.} - -\item{col_label}{(\code{string})\cr -String indicating the column label. Default is \code{"**Overall** \nN = {style_number(N)}"}} +\item{surv_df}{(\code{data.frame})\cr +The results \code{data.frame} generated by \code{get_surv_times_df()}.} } \value{ -a gtsummary table +A \code{gtsummary} object. } \description{ -Create a gtsummary table with Kaplan-Meier estimated survival estimates -and specified times. -} -\details{ -When the \code{statistic} argument is modified, the statistic labels will likely -also need to be updated. To change the label, call the \code{modify_table_body()} -function to directly update the underlying \code{x$table_body} data frame. +Generates a \code{gtsummary} table from the survival times \code{data.frame} created by +\code{get_surv_times_df()}. The table dynamically adapts to whichever columns the +user leaves in the data frame, allowing for easy customization. } -\section{Methods (by generic)}{ -\itemize{ -\item \code{add_difference_row(tbl_survfit_times)}: Adds survival differences between groups as additional rows to tables created by \code{\link[=tbl_survfit_times]{tbl_survfit_times()}}. +\examples{ +library(survival) +surv_data <- lung +surv_data$status <- surv_data$status - 1 -Difference statistics are calculated using \code{\link[cardx:ard_survival_survfit_diff]{cardx::ard_survival_survfit_diff()}} -for all \code{tbl_survfit_times(times)} variable values, using \code{survfit} formula: +# Example: Replicating "add_overall" functionality. +# 1. Fit the stratified model and extract data +fit_strat <- survfit(Surv(time, status) ~ sex, data = surv_data) +df_strat <- get_surv_times_df(fit_strat, times = c(100, 200)) -\if{html}{\out{
}}\preformatted{survival::survfit(y ~ by, data = data) -}\if{html}{\out{
}} +# 2. Fit the overall unstratified model and extract data +fit_overall <- survfit(Surv(time, status) ~ 1, data = surv_data) +df_overall <- get_surv_times_df(fit_overall, times = c(100, 200)) -where \code{y}, \code{by} and \code{data} are the inputs of the same names to the \code{tbl_survfit_times()} object \code{x}. +# 3. Rename the unstratified label to "Overall" (instead of "All") +df_overall$Strata <- "Overall" -Pairwise differences are calculated relative to the specified \code{by} variable's specified reference level. +# 4. Combine rows and render the single stacked table +combined_df <- rbind(df_overall, df_strat) +tbl_survfit_times(combined_df) -}} -\examples{ -# Example 1 ---------------------------------- -tbl_survfit_times( - data = cards::ADTTE, - by = "TRTA", - times = c(30, 60), - label = "Day {time}" -) |> - add_overall() -# Example 2 - Survival Differences ----------- -tbl_survfit_times( - data = cards::ADTTE, - by = "TRTA", - times = c(30, 60), - label = "Day {time}" -) |> - add_difference_row(reference = "Placebo") } diff --git a/tests/testthat/_snaps/add_difference_row.md b/tests/testthat/_snaps/add_difference_row.md deleted file mode 100644 index 59ff3ebb3..000000000 --- a/tests/testthat/_snaps/add_difference_row.md +++ /dev/null @@ -1,56 +0,0 @@ -# add_difference_row.tbl_survfit_times() works - - Code - as.data.frame(tbl1) - Output - Placebo \n(N = 86) Xanomeline High Dose \n(N = 84) Xanomeline Low Dose \n(N = 84) - 1 Time 60 - 2 Patients remaining at risk 59 14 20 - 3 Event Free Rate (%) 76.8 24.3 31.1 - 4 95% CI (67.7, 86.0) (13.9, 34.7) (20.2, 41.9) - 5 Difference in Event Free Rates 52.54 45.77 - 6 95% CI (38.65, 66.43) (31.57, 59.97) - 7 p-value (Z-test) <0.0001 <0.0001 - 8 Time 120 - 9 Patients remaining at risk 45 4 8 - 10 Event Free Rate (%) 64.3 9.2 14.7 - 11 95% CI (53.6, 75.1) (1.1, 17.2) (5.7, 23.7) - 12 Difference in Event Free Rates 55.16 49.68 - 13 95% CI (41.76, 68.56) (35.70, 63.65) - 14 p-value (Z-test) <0.0001 <0.0001 - ---- - - Code - as.data.frame(tbl3) - Output - Placebo \n(N = 86) Xanomeline High Dose \n(N = 84) Xanomeline Low Dose \n(N = 84) - 1 Time 60 - 2 Patients remaining at risk 59 14 20 - 3 Event Free Rate (%) 76.8 24.3 31.1 - 4 95% CI (67.7, 86.0) (13.9, 34.7) (20.2, 41.9) - 5 Survival Difference (Survival Difference Standard Error) 52.5 (7.1) 45.8 (7.2) - 6 z statistic (p = p-value) 741.5 (p = <0.001) 631.8 (p = <0.001) - 7 Time 120 - 8 Patients remaining at risk 45 4 8 - 9 Event Free Rate (%) 64.3 9.2 14.7 - 10 95% CI (53.6, 75.1) (1.1, 17.2) (5.7, 23.7) - 11 Survival Difference (Survival Difference Standard Error) 55.2 (6.8) 49.7 (7.1) - 12 z statistic (p = p-value) 806.8 (p = <0.001) 696.7 (p = <0.001) - -# add_difference_row.tbl_survfit_times() error messaging works - - Code - add_difference_row(tbl_survfit_times(data = cards::ADTTE, times = c(30, 60)), "Placebo") - Condition - Error in `add_difference_row()`: - ! Cannot run `add_difference_row()` when `tbl_survfit_times()` does not include a `by` argument. - ---- - - Code - add_difference_row(tbl_survfit_times(data = cards::ADTTE, by = TRTA, times = c(30, 60)), "No Treatment") - Condition - Error in `add_difference_row()`: - ! The `reference` argument must be one of "Placebo", "Xanomeline High Dose", and "Xanomeline Low Dose". - diff --git a/tests/testthat/_snaps/tbl_survfit_times.md b/tests/testthat/_snaps/tbl_survfit_times.md index d53c80f25..a889e6e7f 100644 --- a/tests/testthat/_snaps/tbl_survfit_times.md +++ b/tests/testthat/_snaps/tbl_survfit_times.md @@ -1,73 +1,34 @@ -# tbl_survfit_times() works +# tbl_survfit_times() works for stratified data Code as.data.frame(tbl) Output - Placebo \n(N = 86) Xanomeline High Dose \n(N = 84) Xanomeline Low Dose \n(N = 84) - 1 Time 30 - 2 Patients remaining at risk 69 38 42 - 3 Event Free Rate (%) 84.4 53.0 53.4 - 4 95% CI (76.7, 92.2) (41.7, 64.4) (42.3, 64.4) - 5 Time 60 - 6 Patients remaining at risk 59 14 20 - 7 Event Free Rate (%) 76.8 24.3 31.1 - 8 95% CI (67.7, 86.0) (13.9, 34.7) (20.2, 41.9) - ---- - - Code - as.data.frame(tbl) - Output - Drug A \n(N = 98) Drug B \n(N = 102) - 1 Time 12 - 2 Patients remaining at risk 89 88 - 3 Event Free Rate (%) 90.8 86.3 - 4 95% CI (85.1, 96.5) (79.6, 93.0) - 5 Time 15 - 6 Patients remaining at risk 83 75 - 7 Event Free Rate (%) 84.7 73.5 - 8 95% CI (77.6, 91.8) (65.0, 82.1) - ---- + Time + 1 Placebo N at Risk 69.0 59.0 + 2 Survival 0.84 0.77 + 3 95% CI (0.77, 0.93) (0.68, 0.87) + 4 Xanomeline High Dose N at Risk 38.0 14.0 + 5 Survival 0.53 0.24 + 6 95% CI (0.43, 0.66) (0.16, 0.37) + 7 Xanomeline Low Dose N at Risk 42.0 20.0 + 8 Survival 0.53 0.31 + 9 95% CI (0.43, 0.66) (0.22, 0.44) + +# tbl_survfit_times() works for unstratified data Code as.data.frame(tbl) Output - Overall \n(N = 254) - 1 Time 30 - 2 Patients remaining at risk 149 - 3 Event Free Rate (%) 64.1 - 4 90% CI (59.1, 69.4) - 5 Time 60 - 6 Patients remaining at risk 93 - 7 Event Free Rate (%) 45.7 - 8 90% CI (40.5, 51.5) - -# tbl_survfit_times(by) messaging - - Code - tbl_survfit_times(data = cards::ADTTE, by = everything(), times = 30) - Condition - Error in `tbl_survfit_times()`: - ! The `by` argument must be length 1 or empty. - ---- - - Code - tbl_survfit_times(data = dplyr::rename(gtsummary::trial, time = trt), by = "time", y = "survival::Surv(ttdeath, death)", - times = 30) - Condition - Error in `tbl_survfit_times()`: - ! The `by` column cannot be named "time". + Time + 1 N at Risk 149.0 93.0 + 2 Survival 0.64 0.46 + 3 90% CI (0.58, 0.70) (0.40, 0.53) -# add_overall.tbl_survfit_times() works +# tbl_survfit_times() adapts to user modifications (dropping columns) Code - as.data.frame(add_overall(tbl_survfit_times(data = cards::ADTTE, by = "TRTA", times = 30, label = "Day {time}"), last = TRUE, col_label = "**All Participants** \nN = {n}")) + tbl_df Output - Placebo \n(N = 86) Xanomeline High Dose \n(N = 84) Xanomeline Low Dose \n(N = 84) All Participants \nN = 254 - 1 Day 30 - 2 Patients remaining at risk 69 38 42 149 - 3 Event Free Rate (%) 84.4 53.0 53.4 64.1 - 4 95% CI (76.7, 92.2) (41.7, 64.4) (42.3, 64.4) (58.0, 70.2) + Time + 1 Survival 0.64 diff --git a/tests/testthat/test-add_difference_row.R b/tests/testthat/test-add_difference_row.R deleted file mode 100644 index 8b28e4b4e..000000000 --- a/tests/testthat/test-add_difference_row.R +++ /dev/null @@ -1,73 +0,0 @@ -skip_if_pkg_not_installed(c("survival", "withr")) - -tbl <- - tbl_survfit_times( - data = cards::ADTTE, - by = TRTA, - times = c(60, 120) - ) - -test_that("add_difference_row.tbl_survfit_times() works", { - withr::local_options(list(width = 200)) - expect_silent( - tbl1 <- tbl |> - add_difference_row(reference = "Placebo") - ) - expect_snapshot(as.data.frame(tbl1)) - - # works with different reference column - expect_silent( - tbl2 <- tbl |> - add_difference_row(reference = "Xanomeline Low Dose") - ) - expect_equal( - as.data.frame(tbl2) |> names(), - c("", "Xanomeline Low Dose \n(N = 84)", "Placebo \n(N = 86)", "Xanomeline High Dose \n(N = 84)") - ) - - # works with custom statistics/formats - expect_silent( - tbl3 <- tbl |> - add_difference_row( - reference = "Placebo", - statistic = c("{estimate} ({std.error})", "{statistic} (p = {p.value})"), - pvalue_fun = label_style_pvalue(digits = 3), - estimate_fun = label_roche_number(digits = 1, scale = 100) - ) - ) - expect_snapshot(as.data.frame(tbl3)) - - # no error if overall column is present - expect_silent( - tbl4 <- tbl |> - add_overall(last = TRUE) |> - add_difference_row(reference = "Xanomeline High Dose") - ) - expect_equal( - as.data.frame(tbl4) |> names(), - c("", "Xanomeline High Dose \n(N = 84)", "Placebo \n(N = 86)", "Xanomeline Low Dose \n(N = 84)", "All Participants \nN = 254") - ) -}) - -test_that("add_difference_row.tbl_survfit_times() error messaging works", { - withr::local_options(list(width = 200)) - - expect_snapshot( - error = TRUE, - tbl_survfit_times( - data = cards::ADTTE, - times = c(30, 60) - ) |> - add_difference_row("Placebo") - ) - - expect_snapshot( - error = TRUE, - tbl_survfit_times( - data = cards::ADTTE, - by = TRTA, - times = c(30, 60) - ) |> - add_difference_row("No Treatment") - ) -}) diff --git a/tests/testthat/test-annotate_gg_km.R b/tests/testthat/test-annotate_gg_km.R index a15f0a0dc..6378beed8 100644 --- a/tests/testthat/test-annotate_gg_km.R +++ b/tests/testthat/test-annotate_gg_km.R @@ -24,6 +24,16 @@ p_cow <- cowplot::ggdraw(p_base) # Used to trigger cowplot errors # Cox Data cox_df <- data.frame(Term = "B vs A", HR = "1.2", p = "0.05") +# Surv Times Data (for testing annotate_surv_med) +surv_df <- data.frame( + Strata = c("A", "B"), + Time = c(100, 100), + Survival = c("0.85", "0.75"), + check.names = FALSE +) +# Add rownames to trigger the table's rownames-to-column shifting logic +rownames(surv_df) <- c("Arm A", "Arm B") + # ------------------------------------------------------------------------------ # 2. TESTS FOR annotate_riskdf() # ------------------------------------------------------------------------------ @@ -62,32 +72,29 @@ test_that("annotate_surv_med() handles all branches and inputs", { expect_no_error( annotate_surv_med( p_base, - fit_strat, + surv_tbl = surv_df, table_position = c(x = 0.5, y = 0.5, w = 0.1, h = 0.1) ) ) # Success: Cowplot input - expect_no_error(annotate_surv_med(p_cow, fit_strat)) - - # Success: Unstratified - expect_no_error(annotate_surv_med(p_base, fit_single)) + expect_no_error(annotate_surv_med(p_cow, surv_tbl = surv_df)) # Error: Invalid plot expect_error( - annotate_surv_med(list(), fit_strat), + annotate_surv_med(list(), surv_tbl = surv_df), "must be a ggplot or cowplot object" ) - # Error: Invalid survfit + # Error: Invalid surv_tbl (Now expects a data.frame, not survfit) expect_error( - annotate_surv_med(p_base, list()), - "must be a survfit object" + annotate_surv_med(p_base, surv_tbl = list()), + "must be a data.frame" ) # Fill Logic: Custom color and FALSE - expect_no_error(annotate_surv_med(p_base, fit_strat, fill = "red")) - expect_no_error(annotate_surv_med(p_base, fit_strat, fill = FALSE)) + expect_no_error(annotate_surv_med(p_base, surv_tbl = surv_df, fill = "red")) + expect_no_error(annotate_surv_med(p_base, surv_tbl = surv_df, fill = FALSE)) }) # ------------------------------------------------------------------------------ @@ -103,7 +110,7 @@ test_that("annotate_coxph() handles all branches and inputs", { "must be a ggplot or cowplot object" ) - # Error: Invalid dataframe ( + # Error: Invalid dataframe expect_error(annotate_coxph(p_base, list()), "must be a data.frame") # Fill Logic: Custom color diff --git a/tests/testthat/test-get_surv_diff_df.R.R b/tests/testthat/test-get_surv_diff_df.R.R new file mode 100644 index 000000000..a5e092fb3 --- /dev/null +++ b/tests/testthat/test-get_surv_diff_df.R.R @@ -0,0 +1,86 @@ +# tests/testthat/test-get_surv_diff_df.R + +library(survival) + +# Setup: Create a standard survfit object to be used across multiple tests. +# We use the standard 'lung' dataset and convert 'sex' to a factor to ensure +# clear reference level matching. +surv_data <- survival::lung +surv_data$status <- surv_data$status - 1 +surv_data$sex <- factor(surv_data$sex, labels = c("Male", "Female")) +fit_km <- survfit(Surv(time, status) ~ sex, data = surv_data) + +test_that("get_surv_diff_df returns a correctly structured data.frame", { + # Why: Verifying the core functionality and output shape ensures downstream + # combining with get_surv_times_df() will not fail due to column mismatches. + res <- get_surv_diff_df(fit_km, times = c(100, 200), reference = "Male") + + expect_s3_class(res, "data.frame") + expect_equal(nrow(res), 2) + expect_equal( + names(res), + c("Strata", "Time", "N at Risk", "Survival", "95% CI", "p-value") + ) + expect_equal(res$Time, c(100, 200)) + + # Why: Ensures the strata parsing correctly labels the comparison + expect_true(all(res$Strata == "Female vs Male (Diff)")) + + # 'N at Risk' should be strictly missing for difference rows + expect_true(all(is.na(res$`N at Risk`))) +}) + +test_that("get_surv_diff_df correctly handles scale parameter", { + # Why: Users often want to present survival as percentages (scale = 100). + # We test this to ensure the mathematical multiplication is applied to both + # the estimate and the CI bounds before string formatting. + res_unscaled <- get_surv_diff_df( + fit_km, + times = 100, reference = "Male", scale = 1 + ) + res_scaled <- get_surv_diff_df( + fit_km, + times = 100, reference = "Male", scale = 100 + ) + + # Extract numeric values from the formatted strings for comparison + est_unscaled <- as.numeric(res_unscaled$Survival) + est_scaled <- as.numeric(res_scaled$Survival) + + expect_equal(round(est_scaled, 0), est_unscaled * 100) +}) + +test_that("get_surv_diff_df correctly handles custom confidence intervals", { + # Why: Custom confidence levels (e.g., 90% or 99%) should dynamically alter + # both the statistical calculation and the resulting column header. + res_90 <- get_surv_diff_df( + fit_km, + times = 100, reference = "Male", conf_int = 0.90 + ) + + expect_true("90% CI" %in% names(res_90)) + expect_false("95% CI" %in% names(res_90)) +}) + +test_that("get_surv_diff_df triggers aborts on invalid inputs", { + # Why: Rigorous type-checking ensures the function fails fast and informs the + # user exactly what went wrong before attempting to call cardx or dplyr. + + # 1. Invalid fit_km object + expect_error( + get_surv_diff_df(fit_km = "not_a_model", times = 100, reference = "Male"), + "`fit_km` must be a survfit object." + ) + + # 2. Invalid times (non-numeric) + expect_error( + get_surv_diff_df(fit_km = fit_km, times = "100", reference = "Male"), + "`times` must be a non-empty numeric vector." + ) + + # 3. Invalid times (empty vector) + expect_error( + get_surv_diff_df(fit_km = fit_km, times = numeric(0), reference = "Male"), + "`times` must be a non-empty numeric vector." + ) +}) diff --git a/tests/testthat/test-get_surv_times_df.R b/tests/testthat/test-get_surv_times_df.R new file mode 100644 index 000000000..8b2d0529b --- /dev/null +++ b/tests/testthat/test-get_surv_times_df.R @@ -0,0 +1,72 @@ +skip_if_pkg_not_installed(c("survival")) + +test_that("get_surv_times_df() works for stratified and unstratified models", { + # Stratified Model + fit_strat <- survival::survfit( + survival::Surv(AVAL, 1 - CNSR) ~ TRTA, + data = cards::ADTTE + ) + + df_strat <- get_surv_times_df(fit_strat, times = c(30, 60)) + + expect_s3_class(df_strat, "data.frame") + expect_equal( + colnames(df_strat), + c("Strata", "Time", "N at Risk", "Survival", "95% CI") + ) + expect_equal(unique(df_strat$Time), c(30, 60)) + expect_true(length(unique(df_strat$Strata)) > 1) + + # Unstratified Model + fit_unstrat <- survival::survfit( + survival::Surv(AVAL, 1 - CNSR) ~ 1, + data = cards::ADTTE + ) + + df_unstrat <- get_surv_times_df(fit_unstrat, times = c(30, 60)) + + expect_s3_class(df_unstrat, "data.frame") + expect_equal(unique(df_unstrat$Strata), "All") +}) + +test_that("get_surv_times_df() applies scale and conf_int correctly", { + fit <- survival::survfit( + survival::Surv(AVAL, 1 - CNSR) ~ 1, + data = cards::ADTTE + ) + + df_scaled <- get_surv_times_df( + fit, + times = 30, + conf_int = 0.90, + scale = 100 + ) + + # CI column should rename automatically based on conf_int + expect_true("90% CI" %in% colnames(df_scaled)) + expect_false("95% CI" %in% colnames(df_scaled)) + + # Ensure the value string reflects the x100 scaling + # (e.g., "95.00" vs "0.95") + surv_val <- as.numeric(df_scaled$Survival[1]) + expect_true(surv_val > 1) +}) + +test_that("get_surv_times_df() catches invalid inputs", { + expect_error( + get_surv_times_df(cards::ADTTE, times = c(30, 60)), + "`fit_km` must be a survfit object", + fixed = TRUE + ) + + fit <- survival::survfit( + survival::Surv(AVAL, 1 - CNSR) ~ 1, + data = cards::ADTTE + ) + + expect_error( + get_surv_times_df(fit, times = NULL), + "`times` must be a non-empty numeric vector", + fixed = TRUE + ) +}) diff --git a/tests/testthat/test-gg_km.R b/tests/testthat/test-gg_km.R index 4808401aa..dc0d283d8 100644 --- a/tests/testthat/test-gg_km.R +++ b/tests/testthat/test-gg_km.R @@ -15,6 +15,9 @@ model_formula <- rlang::new_formula( ) fit_kmg01 <- survival::survfit(model_formula, anl) +# Extract pre-calculated data frame for annotate_surv_med +surv_df <- get_surv_times_df(fit_kmg01, times = c(100, 200)) + test_that("gg_km() works and handles annotations correctly", { expect_no_error( surv_plot_data <- process_survfit(fit_kmg01) @@ -33,7 +36,7 @@ test_that("gg_km() works and handles annotations correctly", { # 1. Test floating annotations (These can be safely piped together) expect_no_error( plt_floats <- gg_km(surv_plot_data) |> - annotate_surv_med(fit_kmg01) |> + annotate_surv_med(surv_tbl = surv_df) |> annotate_coxph(coxph_tbl) ) @@ -66,7 +69,7 @@ test_that("plotlist attribute is preserved through annotate_* stacking", { expect_s3_class(plist_risk$table, "ggplot") # Single floating annotation: annotate_surv_med -> df2gg_floating - plt_med <- base_plt |> annotate_surv_med(fit_kmg01) + plt_med <- base_plt |> annotate_surv_med(surv_tbl = surv_df) plist_med <- attr(plt_med, "plotlist") expect_true(!is.null(plist_med)) expect_named(plist_med, c("main", "table")) @@ -74,7 +77,7 @@ test_that("plotlist attribute is preserved through annotate_* stacking", { # Stacked floating annotations: surv_med |> coxph plt_stacked <- base_plt |> - annotate_surv_med(fit_kmg01) |> + annotate_surv_med(surv_tbl = surv_df) |> annotate_coxph(coxph_tbl) plist_top <- attr(plt_stacked, "plotlist") diff --git a/tests/testthat/test-tbl_survfit_times.R b/tests/testthat/test-tbl_survfit_times.R index 1fae42dd0..0813134b9 100644 --- a/tests/testthat/test-tbl_survfit_times.R +++ b/tests/testthat/test-tbl_survfit_times.R @@ -1,91 +1,108 @@ -skip_if_pkg_not_installed(c("survival", "withr")) +skip_if_pkg_not_installed(c("survival", "withr", "cards", "gtsummary")) -test_that("tbl_survfit_times() works", { +test_that("tbl_survfit_times() works for stratified data", { withr::local_options(list(width = 120)) - # Using the default value of the `y` argument - expect_silent( - tbl <- - tbl_survfit_times( - data = cards::ADTTE, - by = "TRTA", - times = c(30, 60) - ) - ) - expect_snapshot(as.data.frame(tbl)) - # Specifying the `y` argument - expect_silent( - tbl <- - tbl_survfit_times( - data = gtsummary::trial, - by = "trt", - y = "survival::Surv(ttdeath, death)", - times = c(12, 15) - ) + fit <- survival::survfit( + survival::Surv(AVAL, 1 - CNSR) ~ TRTA, + data = cards::ADTTE ) + df <- get_surv_times_df(fit, times = c(30, 60)) + + expect_silent(tbl <- tbl_survfit_times(df)) expect_snapshot(as.data.frame(tbl)) +}) + +test_that("tbl_survfit_times() works for unstratified data", { + withr::local_options(list(width = 120)) - # works for unstratified models - expect_silent( - tbl <- - tbl_survfit_times( - data = cards::ADTTE, - times = c(30, 60), - method.args = list(conf.int = 0.90) - ) + fit <- survival::survfit( + survival::Surv(AVAL, 1 - CNSR) ~ 1, + data = cards::ADTTE ) + # Also testing passing a custom confidence interval representation + df <- get_surv_times_df(fit, times = c(30, 60), conf_int = 0.90) + + expect_silent(tbl <- tbl_survfit_times(df)) expect_snapshot(as.data.frame(tbl)) +}) + +test_that("tbl_survfit_times() adapts to user modifications (dropping columns)", { + withr::local_options(list(width = 120)) - # works with NSE inputs in `method.args()` - expect_equal( - tbl_survfit_times( - data = cards::ADTTE, - times = c(30, 60), - method.args = list(id = SEX) - ) |> - gtsummary::gather_ard() |> - getElement("tbl_survfit_times") |> - dplyr::filter(variable == "time") |> - dplyr::select(-fmt_fun), - survival::survfit(survival::Surv(time = AVAL, event = 1 - CNSR) ~ 1, data = cards::ADTTE, id = SEX) |> - cardx::ard_survival_survfit(times = c(30, 60)) |> - dplyr::filter(variable == "time") |> - dplyr::select(-fmt_fun) + fit <- survival::survfit( + survival::Surv(AVAL, 1 - CNSR) ~ 1, + data = cards::ADTTE ) + df <- get_surv_times_df(fit, times = 30) + + # User dynamically drops the N at Risk column and renames CI + df_custom <- df[, c("Strata", "Time", "Survival")] + + expect_silent(tbl <- tbl_survfit_times(df_custom)) + + # Ensure the resulting gtsummary table doesn't break and only contains Survival + tbl_df <- as.data.frame(tbl) + expect_false(any(grepl("N at Risk", tbl_df[[1]]))) + expect_snapshot(tbl_df) }) -test_that("tbl_survfit_times(by) messaging", { +test_that("tbl_survfit_times() catches invalid inputs", { withr::local_options(list(width = 120)) - expect_snapshot( - error = TRUE, - tbl_survfit_times( - data = cards::ADTTE, - by = everything(), - times = 30 - ) + + # Fails when not a data frame + expect_error( + tbl_survfit_times(list(Time = 30, Survival = 0.5)), + "`surv_df` must be a data.frame", + fixed = TRUE + ) + + fit <- survival::survfit( + survival::Surv(AVAL, 1 - CNSR) ~ 1, + data = cards::ADTTE ) + df <- get_surv_times_df(fit, times = c(30, 60)) - expect_snapshot( - error = TRUE, - tbl_survfit_times( - data = gtsummary::trial |> dplyr::rename(time = trt), - by = "time", - y = "survival::Surv(ttdeath, death)", - times = 30 - ) + # Fails when missing mandatory columns + df_missing_time <- df[, c("Strata", "Survival")] + expect_error( + tbl_survfit_times(df_missing_time), + "`surv_df` must contain at least 'Strata' and 'Time' columns", + fixed = TRUE ) }) -test_that("add_overall.tbl_survfit_times() works", { - withr::local_options(list(width = 180)) - expect_snapshot( - tbl_survfit_times( - data = cards::ADTTE, - by = "TRTA", - times = 30, - label = "Day {time}" - ) |> - add_overall(last = TRUE, col_label = "**All Participants** \nN = {n}") |> - as.data.frame() +test_that("tbl_survfit_times correctly renders the p-value row from combined data", { + # 1. Setup data and fit the model + surv_data <- survival::lung + surv_data$status <- surv_data$status - 1 + surv_data$sex <- factor(surv_data$sex, labels = c("Male", "Female")) + + fit_km <- survival::survfit(survival::Surv(time, status) ~ sex, data = surv_data) + + # 2. Extract standard stats and differences + df_surv <- get_surv_times_df(fit_km, times = 100) + df_diff <- get_surv_diff_df(fit_km, times = 100, reference = "Male") + + # 3. Combine and render + combined_df <- dplyr::bind_rows(df_surv, df_diff) + res_tbl <- tbl_survfit_times(combined_df) + + # 4. Extract the underlying table body from the gtsummary object + tbl_body <- res_tbl$table_body + + # 5. Assertions + # The `.get_single_time_table` maps the column name to the `variable` + # or `label` column inside gtsummary's internal structure. + expect_true( + "p-value" %in% tbl_body$variable || "p-value" %in% tbl_body$label, + info = "The 'p-value' row was dropped and did not render in the final gtsummary table." + ) + + # Optionally, extract the specific row to ensure a value actually populated + p_val_row <- tbl_body[tbl_body$variable == "p-value" | tbl_body$label == "p-value", ] + expect_false( + all(is.na(p_val_row)), + info = "The p-value row was created, but all values inside it are NA." ) })