diff --git a/DESCRIPTION b/DESCRIPTION index 3a09e8e3c..caaeda483 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -56,9 +56,11 @@ Imports: teal.widgets (>= 0.4.3), utils Suggests: + DT (>= 0.13), knitr (>= 1.42), MultiAssayExperiment, rmarkdown (>= 2.23), + shinytest2 (>= 0.4.1), SummarizedExperiment, testthat (>= 3.2.2), withr (>= 3.0.2) @@ -72,7 +74,7 @@ Config/Needs/verdepcheck: rstudio/shiny, rstudio/bslib, mllg/checkmate, daroczig/logger, plotly/plotly, r-lib/R6, daattali/shinycssloaders, daattali/shinyjs, dreamRs/shinyWidgets, insightsengineering/teal.data, insightsengineering/teal.logger, insightsengineering/teal.widgets, - yihui/knitr, bioc::MultiAssayExperiment, bioc::SummarizedExperiment, + rstudio/DT, yihui/knitr, bioc::MultiAssayExperiment, bioc::SummarizedExperiment, rstudio/rmarkdown, r-lib/testthat, r-lib/withr, bioc::matrixStats Config/Needs/website: insightsengineering/nesttemplate Encoding: UTF-8 diff --git a/R/FilteredData.R b/R/FilteredData.R index 2b2e68138..d447fc6fa 100644 --- a/R/FilteredData.R +++ b/R/FilteredData.R @@ -532,7 +532,7 @@ FilteredData <- R6::R6Class( # nolint include_js_files(pattern = "togglePanelItems"), class = "teal-slice", bslib::accordion( - id = ns("main_filter_accordian"), + id = ns("main_filter_accordion"), bslib::accordion_panel( "Filter Data", tags$div( @@ -578,8 +578,8 @@ FilteredData <- R6::R6Class( # nolint }); ", ns("additional_filter_helpers"), - ns("main_filter_accordian"), - ns("main_filter_accordian"), + ns("main_filter_accordion"), + ns("main_filter_accordion"), ns("additional_filter_helpers") ) ) @@ -710,7 +710,7 @@ FilteredData <- R6::R6Class( # nolint tags$div( class = "teal-slice", bslib::accordion( - id = ns("main_filter_accordian"), + id = ns("main_filter_accordion"), bslib::accordion_panel( title = "Active Filter Summary", tags$div( diff --git a/R/FilteredDataset.R b/R/FilteredDataset.R index 0f3d12929..f8d57a6a8 100644 --- a/R/FilteredDataset.R +++ b/R/FilteredDataset.R @@ -242,7 +242,7 @@ FilteredDataset <- R6::R6Class( # nolint include_css_files("filter-panel"), include_js_files(pattern = "icons"), bslib::accordion( - id = session$ns("dataset_filter_accordian"), + id = session$ns("dataset_filter_accordion"), class = "teal-slice-dataset-filter", bslib::accordion_panel( dataname, @@ -298,10 +298,10 @@ FilteredDataset <- R6::R6Class( # nolint }); ", session$ns("filter_util_icons"), - session$ns("dataset_filter_accordian"), - session$ns("dataset_filter_accordian"), + session$ns("dataset_filter_accordion"), + session$ns("dataset_filter_accordion"), session$ns("active_filter_badge"), - session$ns("dataset_filter_accordian") + session$ns("dataset_filter_accordion") ) ) ) @@ -344,7 +344,7 @@ FilteredDataset <- R6::R6Class( # nolint $('#%s .accordion-button.collapsed').click(); }", session$ns("add_panel"), - session$ns("dataset_filter_accordian") + session$ns("dataset_filter_accordion") ) ) ), @@ -387,8 +387,8 @@ FilteredDataset <- R6::R6Class( # nolint # If the accordion input is `NULL` it is being collapsed. # It has the accordion panel label if it is expanded. - observeEvent(input$dataset_filter_accordian, ignoreNULL = FALSE, { - if (is.null(input$dataset_filter_accordian)) { + observeEvent(input$dataset_filter_accordion, ignoreNULL = FALSE, { + if (is.null(input$dataset_filter_accordion)) { # Hiding the `add_panel` dropdown and changing the minus icon to plus # TODO: simplify this implementation. This is done in multiple places shinyjs::runjs( diff --git a/tests/testthat/helpers-shinytest2.R b/tests/testthat/helpers-shinytest2.R new file mode 100644 index 000000000..7ecbf739e --- /dev/null +++ b/tests/testthat/helpers-shinytest2.R @@ -0,0 +1,26 @@ +default_idle_timeout <- 20000 # Wait (ms) at most until idle +default_idle_duration <- 200 # Time (ms) it is idle + +# Check visibility (borrowed from teal.widgets/tests/testthat/helpers-utils.R) +is_visible <- function(app_driver, element) { + js_script <- sprintf(" + Array.from(document.querySelectorAll('%s')).map(el => { + return el.checkVisibility() && (el.textContent.trim().length > 0 || el.children.length > 0); + }); + ", element) + + any(unlist(app_driver$get_js(js_script))) +} + +# Write a js code to extract the classes +get_attribute <- function(selector, attribute) { + sprintf( + "Array.from(document.querySelectorAll('%s')).map(el => el.getAttribute('%s'))", + selector, attribute + ) +} + +is_existing <- function(app_driver, element) { + js_script <- sprintf("document.querySelectorAll('%s').length > 0;", element) + app_driver$get_js(js_script) +} diff --git a/tests/testthat/helpers-testing-depth.R b/tests/testthat/helpers-testing-depth.R new file mode 100644 index 000000000..99e129598 --- /dev/null +++ b/tests/testthat/helpers-testing-depth.R @@ -0,0 +1,51 @@ +#' Returns testing depth set by session option or by environmental variable. +#' +#' @details Looks for the session option `TESTING_DEPTH` first. +#' If not set, takes the system environmental variable `TESTING_DEPTH`. +#' If neither is set, then returns 3 by default. +#' If the value of `TESTING_DEPTH` is not a numeric of length 1, then returns 3. +#' +#' @return `numeric(1)` the testing depth. +#' +get_testing_depth <- function() { + default_depth <- 3 + depth <- getOption("TESTING_DEPTH", Sys.getenv("TESTING_DEPTH", default_depth)) + depth <- tryCatch( + as.numeric(depth), + error = function(error) default_depth, + warning = function(warning) default_depth + ) + if (length(depth) != 1) depth <- default_depth + depth +} + +#' Skipping tests in the testthat pipeline under specific scope +#' @description This function should be used per each `testthat::test_that` call. +#' Each of the call should specify an appropriate depth value. +#' The depth value will set the appropriate scope so more/less time consuming tests could be recognized. +#' The environment variable `TESTING_DEPTH` is used for changing the scope of `testthat` pipeline. +#' `TESTING_DEPTH` interpretation for each possible value: +#' \itemize{ +#' \item{0}{no tests at all} +#' \item{1}{fast - small scope - executed on every commit} +#' \item{3}{medium - medium scope - daily integration pipeline} +#' \item{5}{slow - all tests - daily package tests} +#' } +#' @param depth `numeric` the depth of the testing evaluation, +#' has opposite interpretation to environment variable `TESTING_DEPTH`. +#' So e.g. `0` means run it always and `5` means a heavy test which should be run rarely. +#' If the `depth` argument is larger than `TESTING_DEPTH` then the test is skipped. +#' @importFrom testthat skip +#' @return `NULL` or invoke an error produced by `testthat::skip` +#' @note By default `TESTING_DEPTH` is equal to 3 if there is no environment variable for it. +#' By default `depth` argument lower or equal to 3 will not be skipped because by default `TESTING_DEPTH` +#' is equal to 3. To skip <= 3 depth tests then the environment variable has to be lower than 3 respectively. +skip_if_too_deep <- function(depth) { # nolintr + checkmate::assert_numeric(depth, len = 1, lower = 0, upper = 5) + testing_depth <- get_testing_depth() # by default 3 if there are no env variable + if (testing_depth < depth) { + testthat::skip(paste("testing depth", testing_depth, "is below current testing specification", depth)) + } +} + +default_idle_timeout <- 20000 diff --git a/tests/testthat/test-shinytest2_DataframeFilteredDataset.R b/tests/testthat/test-shinytest2_DataframeFilteredDataset.R new file mode 100644 index 000000000..644496233 --- /dev/null +++ b/tests/testthat/test-shinytest2_DataframeFilteredDataset.R @@ -0,0 +1,243 @@ +# Create app +# ... arguments are passed to teal_slices which is used by set_filter_state +local_app_driver <- function(..., + name = "filteredData", + variant = sprintf("app_driver_%s", name), + envir = parent.frame()) { + testthat::skip_if_not_installed("shinytest2") + skip_if_too_deep(5) + + # create a FilteredData object + datasets <- init_filtered_data(list(iris = iris, mtcars = mtcars)) + + # setting initial state + set_filter_state( + datasets = datasets, + filter = teal_slices( + ..., + count_type = "all", + allow_add = TRUE + ) + ) + + ui <- fluidPage( + fluidRow( + column( + width = 9, + tabsetPanel( + tabPanel(title = "iris", DT::DTOutput("iris_table")), + tabPanel(title = "mtcars", DT::DTOutput("mtcars_table")) + ) + ), + # ui for the filter panel + column( + width = 3, + # What we want to test: + datasets$ui_filter_panel("filter_panel") + ) + ) + ) + + server <- function(input, output, session) { + # this is the shiny server function for the filter panel and the datasets + # object can now be used inside the application + datasets$srv_filter_panel("filter_panel") + + # get the filtered datasets and put them inside reactives for analysis + iris_filtered_data <- reactive(datasets$get_data(dataname = "iris", filtered = TRUE)) + mtcars_filtered_data <- reactive(datasets$get_data(dataname = "mtcars", filtered = TRUE)) + + output$iris_table <- DT::renderDataTable(iris_filtered_data()) + output$mtcars_table <- DT::renderDataTable(mtcars_filtered_data()) + } + + app <- shinyApp(ui, server) + app_driver <- shinytest2::AppDriver$new( + app, + name = name, + variant = variant, + timeout = default_idle_timeout, + load_timeout = default_idle_timeout * 2, + wait = TRUE, + seed = 20250626 + ) + withr::defer(app_driver$stop(), envir = envir) + app_driver$wait_for_idle() + app_driver +} + +testthat::describe("Toggle button shows and hide", { + it("'Active Filter Summary' panel", { + app_driver <- local_app_driver() + testthat::expect_true(is_visible(app_driver, "#filter_panel-overview-table")) + app_driver$click(selector = "#filter_panel-overview-main_filter_accordion * button") + app_driver$wait_for_idle(timeout = default_idle_timeout * 2) + testthat::expect_false(is_visible(app_driver, "#filter_panel-overview-table")) + }) + it("'Filter Data' panel", { + app_driver <- local_app_driver() + testthat::expect_true(is_visible(app_driver, "#filter_panel-active-filter_active_vars_contents")) + app_driver$click(selector = "#filter_panel-active-main_filter_accordion > div > div.accordion-header > button") + app_driver$wait_for_idle(timeout = default_idle_timeout * 2) + testthat::expect_false(is_visible(app_driver, "#filter_panel-active-filter_active_vars_contents")) + }) +}) + +testthat::describe("datasets passed to filter data", { + it("creates Active Filter Summary panel for all of them", { + app_driver <- local_app_driver() + selector <- "#filter_panel-active * div.accordion-body * button.accordion-button > div.accordion-title" + text <- app_driver$get_text(selector) + testthat::expect_equal(text, c("iris", "mtcars")) + }) + it("creates Filter Data panel for all of them", { + app_driver <- local_app_driver() + table <- "#filter_panel-overview-table > table > tbody * td:first-child" + text <- app_driver$get_text(table) + testthat::expect_equal(text, c("iris", "mtcars")) + }) +}) + +testthat::describe("teal_slice objects pass to filter data", { + it("displays filter/unfiltered counts in Active Filter Summary", { + app_driver <- local_app_driver( + teal_slice(dataname = "iris", varname = "Species", selected = "virginica"), + teal_slice(dataname = "mtcars", id = "4 cyl", title = "4 Cylinders", expr = "cyl == 4"), + teal_slice(dataname = "mtcars", varname = "mpg", selected = c(20.0, 25.0)) + ) + table <- "#filter_panel-overview-table > table > tbody * td:nth-child(2)" + text <- app_driver$get_text(table) + expect_equal(text, c("50/150", "5/32")) + }) + + it("initializes filter_cards for each teal_slice", { + app_driver <- local_app_driver( + teal_slice(dataname = "iris", varname = "Species", selected = "virginica"), + teal_slice(dataname = "mtcars", id = "4 cyl", title = "4 Cylinders", expr = "cyl == 4"), + teal_slice(dataname = "mtcars", varname = "mpg", selected = c(20.0, 25.0)) + ) + text <- app_driver$get_text("div.filter-card-varname > strong") + expect_equal(text, c("Species", "4 cyl", "mpg")) + }) +}) + +testthat::test_that("Clicking add button on the datasets shows add filter panel", { + app_driver <- local_app_driver() + testthat::expect_true(is_existing(app_driver, "#filter_panel-active-mtcars-add_filter_icon")) + app_driver$click(selector = "#filter_panel-active-mtcars-add_filter_icon") + app_driver$wait_for_idle(duration = default_idle_duration * 4) # Wait for the panel open animation + testthat::expect_true(is_existing(app_driver, "#filter_panel-active-mtcars-mtcars-filter-var_to_add > option")) + testthat::expect_true(is_visible(app_driver, "#filter_panel-active-mtcars-mtcars-filter-var_to_add > option")) +}) + +testthat::test_that("Clicking add and selecting a variable adds the card for a given variable", { + app_driver <- local_app_driver() + app_driver$click(selector = "#filter_panel-active-mtcars-add_filter_icon") + app_driver$wait_for_idle(duration = default_idle_duration * 4) + app_driver$click(selector = "#filter_panel-active-mtcars-mtcars-filter-var_to_add") + app_driver$wait_for_idle(duration = default_idle_duration * 4) + app_driver$click(selector = "#filter_panel-active-mtcars-mtcars-filter-var_to_add_input > div > div > button") + app_driver$wait_for_idle(duration = default_idle_duration * 4) + app_driver$click(selector = "#bs-select-1-8") + app_driver$wait_for_idle(duration = default_idle_duration * 4) + selector <- paste0( + "#filter_panel-active-mtcars-filter-mtcars_am > div.filter-card-header > div.filter-card-title", + "> div.filter-card-varname > strong" + ) + text <- app_driver$get_text(selector = selector) + testthat::expect_equal(text, "am") +}) + +testthat::test_that("include_varnames limits choices in add dropdown", { + iris_vars <- c("Species", "Sepal.Length") + app_driver <- local_app_driver( + # FIXME: If no filter is set it is not possible to know the list of options + teal_slice(dataname = "mtcars", id = "4 cyl", title = "4 Cylinders", expr = "cyl == 4"), + include_varnames = list(iris = iris_vars) + ) + + app_driver$click(selector = "#filter_panel-active-iris-add_filter_icon") + app_driver$wait_for_idle(duration = default_idle_duration * 4) + + options <- app_driver$get_text("#filter_panel-active-iris-iris-filter-var_to_add > option") + testthat::expect_equal(iris_vars, options) +}) + +testthat::test_that("exclude_varnames limits choices in add dropdown", { + iris_vars <- c("Species", "Sepal.Length") + app_driver <- local_app_driver( + # FIXME: If no filter is set it is not possible to know the list of options + teal_slice(dataname = "mtcars", id = "4 cyl", title = "4 Cylinders", expr = "cyl == 4"), + exclude_varnames = list(iris = iris_vars) + ) + + app_driver$click(selector = "#filter_panel-active-iris-add_filter_icon") + app_driver$wait_for_idle(duration = default_idle_duration * 4) + + options <- app_driver$get_text(selector = "#filter_panel-active-iris-iris-filter-var_to_add > option") + testthat::expect_false(any(iris_vars %in% options)) +}) + +testthat::test_that("Remove filter button removes a specific filter card", { + app_driver <- local_app_driver( + teal_slice(dataname = "iris", varname = "Species", selected = "virginica"), + teal_slice(dataname = "mtcars", id = "4 cyl", title = "4 Cylinders", expr = "cyl == 4"), + teal_slice(dataname = "mtcars", varname = "mpg", selected = c(20.0, 25.0)) + ) + selector <- "#filter_panel-active-mtcars-filter-mtcars_mpg-remove" + testthat::expect_true(is_visible(app_driver, selector)) + filters_before <- app_driver$get_text("div.filter-card-varname > strong") + app_driver$click(selector = selector) + app_driver$wait_for_idle(default_idle_duration * 4) + + filters_after <- app_driver$get_text("div.filter-card-varname > strong") + testthat::expect_equal(setdiff(filters_before, filters_after), "mpg") +}) + +testthat::test_that("Remove datasets filters removes all cards.", { + app_driver <- local_app_driver( + teal_slice(dataname = "iris", varname = "Species", selected = "virginica"), + teal_slice(dataname = "mtcars", id = "4 cyl", title = "4 Cylinders", expr = "cyl == 4"), + teal_slice(dataname = "mtcars", varname = "mpg", selected = c(20.0, 25.0)) + ) + + selector <- "#filter_panel-active-mtcars-remove_filters" + testthat::expect_true(is_visible(app_driver, selector)) + filters_before <- app_driver$get_text("div.filter-card-varname > strong") + app_driver$click(selector = selector) + app_driver$wait_for_idle(default_idle_duration * 4) + filters_after <- app_driver$get_text("div.filter-card-varname > strong") + testthat::expect_equal(setdiff(filters_before, filters_after), c("4 cyl", "mpg")) +}) + +testthat::test_that("Remove all filters button removes all cards for all datasets.", { + app_driver <- local_app_driver( + teal_slice(dataname = "iris", varname = "Species", selected = "virginica"), + teal_slice(dataname = "mtcars", id = "4 cyl", title = "4 Cylinders", expr = "cyl == 4"), + teal_slice(dataname = "mtcars", varname = "mpg", selected = c(20.0, 25.0)) + ) + selector <- "#filter_panel-active-remove_all_filters" + testthat::expect_true(is_visible(app_driver, selector)) + app_driver$click(selector = selector) + app_driver$wait_for_idle(default_idle_duration * 4) + filters_after <- app_driver$get_text("div.filter-card-varname > strong") + testthat::expect_true(is.null(filters_after)) +}) + +testthat::test_that("Expanding a card shows filter choices.", { + app_driver <- local_app_driver( + teal_slice(dataname = "mtcars", id = "4 cyl", title = "4 Cylinders", expr = "cyl == 4"), + teal_slice(dataname = "mtcars", varname = "mpg", selected = c(20.0, 25.0)) + ) + + select_4_cyl <- "#filter_panel-active-mtcars-filter-4_cyl > div.filter-card-header" + app_driver$click(selector = select_4_cyl) + app_driver$wait_for_idle(default_idle_duration * 4) + testthat::expect_false(is_existing(app_driver, "#filter_panel-active-mtcars-filter-4_cyl-body")) + + select_mpg <- "#filter_panel-active-mtcars-filter-mtcars_mpg > div.filter-card-header" + app_driver$click(selector = select_mpg) + app_driver$wait_for_idle(default_idle_duration * 4) + testthat::expect_true(is_existing(app_driver, "#filter_panel-active-mtcars-filter-mtcars_mpg-body")) + testthat::expect_true(is_visible(app_driver, "#filter_panel-active-mtcars-filter-mtcars_mpg-body")) +}) diff --git a/tests/testthat/test-shinytest2_MAEFilteredDataset.R b/tests/testthat/test-shinytest2_MAEFilteredDataset.R new file mode 100644 index 000000000..62d549561 --- /dev/null +++ b/tests/testthat/test-shinytest2_MAEFilteredDataset.R @@ -0,0 +1,75 @@ +local_app_driver <- function(name = "filteredData_MAE", + variant = sprintf("app_driver_%s", name), + envir = parent.frame()) { + testthat::skip_if_not_installed("shinytest2") + testthat::skip_if_not_installed("MultiAssayExperiment") + skip_if_too_deep(5) + + data(miniACC, package = "MultiAssayExperiment") + fs <- teal_slices( + teal_slice(dataname = "iris", varname = "Species", selected = "virginica", keep_na = FALSE), + teal_slice( + dataname = "MAE", varname = "years_to_birth", selected = c(30, 50), + keep_na = TRUE, keep_inf = FALSE + ), + teal_slice( + dataname = "MAE", varname = "vital_status", selected = "1", + keep_na = FALSE + ), + teal_slice( + dataname = "MAE", varname = "gender", selected = "female", + keep_na = TRUE + ), + teal_slice( + dataname = "MAE", varname = "ARRAY_TYPE", selected = "", + keep_na = TRUE, experiment = "RPPAArray", arg = "subset" + ) + ) + + # create a FilteredData object + datasets <- init_filtered_data(list(MAE = miniACC, iris = iris)) + datasets$set_filter_state(state = fs) + + ui <- fluidPage( + fluidRow( + # ui for the filter panel + column( + width = 3, + # What we want to test: + datasets$ui_filter_panel("filter_panel") + ) + ) + ) + + server <- function(input, output, session) { + # this is the shiny server function for the filter panel and the datasets + # object can now be used inside the application + datasets$srv_filter_panel("filter_panel") + } + + app <- shinyApp(ui, server) + app_driver <- shinytest2::AppDriver$new( + app, + name = name, + variant = variant, + timeout = default_idle_timeout, + load_timeout = default_idle_timeout * 2, + wait = TRUE, + seed = 20250626 + ) + withr::defer(app_driver$stop(), envir = envir) + app_driver$wait_for_idle() + app_driver +} + +testthat::test_that("Active filter summary has all the experiments.", { + app_driver <- local_app_driver() + + selector <- "#filter_panel-active-MAE-filters > div.shiny-html-output.accordion.shiny-bound-output" + text <- app_driver$get_js(get_attribute(selector, "data-label")) + clean_text <- gsub(pattern = "> ", replacement = "", unlist(text)) + testthat::expect_equal(clean_text, c( + "subjects", "RNASeq2GeneNorm", "gistict", + "RPPAArray", "Mutations", "miRNASeqGene" + )) +})