| Marc Kupietz | efec863 | 2026-09-03 13:13:37 +0200 | [diff] [blame^] | 1 | # Consistency checks of Readme.md against the package. These do not query |
| 2 | # anything and run in milliseconds, unlike the documentation prompting tests in |
| 3 | # test-readme-against-llm.R. They guard against the Readme drifting away from |
| 4 | # the package, which misleads readers and, since generated code is copied from |
| 5 | # it verbatim, LLMs as well. |
| 6 | # |
| 7 | # Readme.md is in .Rbuildignore, so it is absent from the built package and |
| 8 | # these tests skip when checking a tarball. |
| 9 | |
| 10 | readmePath <- function() { |
| 11 | for (path in c("Readme.md", "../Readme.md", "../../Readme.md")) { |
| 12 | if (file.exists(path)) { |
| 13 | return(path) |
| 14 | } |
| 15 | } |
| 16 | NULL |
| 17 | } |
| 18 | |
| 19 | skip_without_readme <- function() { |
| 20 | skip_if_not(!is.null(readmePath()), "Readme.md not found (checking a built package?)") |
| 21 | } |
| 22 | |
| 23 | newsPath <- function() { |
| 24 | for (path in c("NEWS.md", "../NEWS.md", "../../NEWS.md")) { |
| 25 | if (file.exists(path)) { |
| 26 | return(path) |
| 27 | } |
| 28 | } |
| 29 | NULL |
| 30 | } |
| 31 | |
| 32 | # All R code blocks of the Readme, as character vectors of their lines |
| 33 | readmeRCodeBlocks <- function() { |
| 34 | readme <- readLines(readmePath(), warn = FALSE) |
| 35 | starts <- grep("^```[rR]$", readme) |
| 36 | ends <- grep("^```$", readme) |
| 37 | |
| 38 | blocks <- list() |
| 39 | for (start in starts) { |
| 40 | end <- ends[ends > start][1] |
| 41 | if (!is.na(end) && end > start + 1) { |
| 42 | blocks[[length(blocks) + 1]] <- readme[(start + 1):(end - 1)] |
| 43 | } |
| 44 | } |
| 45 | blocks |
| 46 | } |
| 47 | |
| 48 | # Every call in an expression, as a list of calls |
| 49 | callsIn <- function(expression) { |
| 50 | found <- list() |
| 51 | walk <- function(x) { |
| 52 | if (is.call(x)) { |
| 53 | found[[length(found) + 1]] <<- x |
| 54 | for (i in seq_along(x)) { |
| 55 | element <- tryCatch(x[[i]], error = function(e) NULL) |
| 56 | if (!is.null(element)) walk(element) |
| 57 | } |
| 58 | } |
| 59 | } |
| 60 | walk(expression) |
| 61 | found |
| 62 | } |
| 63 | |
| 64 | readmeCalls <- function() { |
| 65 | calls <- list() |
| 66 | for (block in readmeRCodeBlocks()) { |
| 67 | parsed <- tryCatch(parse(text = paste(block, collapse = "\n")), error = function(e) NULL) |
| 68 | if (is.null(parsed)) next |
| 69 | for (expression in parsed) calls <- c(calls, callsIn(expression)) |
| 70 | } |
| 71 | calls |
| 72 | } |
| 73 | |
| 74 | # Parameter names of a function of this package. S4 methods whose signature |
| 75 | # differs from the generic are wrapped in a .local function by methods:: |
| 76 | # setMethod(), whose formals are the ones actually documented and called. |
| 77 | packageFunctionParameters <- function(name) { |
| 78 | method <- tryCatch(getMethod(name, "KorAPConnection"), error = function(e) NULL) |
| 79 | if (is.null(method)) { |
| 80 | plain <- tryCatch(get(name, envir = asNamespace("RKorAPClient")), error = function(e) NULL) |
| 81 | return(if (is.function(plain)) names(formals(plain)) else NULL) |
| 82 | } |
| 83 | |
| 84 | body <- body(method) |
| 85 | isRematched <- is.call(body) && identical(body[[1]], as.name("{")) && |
| 86 | length(body) > 1 && is.call(body[[2]]) && |
| 87 | identical(body[[2]][[1]], as.name("<-")) && |
| 88 | identical(body[[2]][[2]], as.name(".local")) |
| 89 | |
| 90 | names(formals(if (isRematched) eval(body[[2]][[3]]) else method)) |
| 91 | } |
| 92 | |
| 93 | test_that("all R code blocks in the Readme parse", { |
| 94 | skip_without_readme() |
| 95 | |
| 96 | blocks <- readmeRCodeBlocks() |
| 97 | expect_gt(length(blocks), 0) |
| 98 | |
| 99 | for (block in blocks) { |
| 100 | code <- paste(block, collapse = "\n") |
| 101 | parsed <- tryCatch(parse(text = code), error = function(e) e) |
| 102 | expect_false( |
| 103 | inherits(parsed, "error"), |
| 104 | label = paste0( |
| 105 | "Readme code block starting with '", block[1], "' does not parse: ", |
| 106 | if (inherits(parsed, "error")) conditionMessage(parsed) else "" |
| 107 | ) |
| 108 | ) |
| 109 | } |
| 110 | }) |
| 111 | |
| 112 | test_that("every function called in the Readme exists", { |
| 113 | skip_without_readme() |
| 114 | |
| 115 | exports <- getNamespaceExports("RKorAPClient") |
| 116 | called <- unique(vapply( |
| 117 | Filter(function(call) is.name(call[[1]]), readmeCalls()), |
| 118 | function(call) as.character(call[[1]]), |
| 119 | character(1) |
| 120 | )) |
| 121 | expect_gt(length(called), 0) |
| 122 | |
| 123 | # functions of this package must exist, others may come from any package the |
| 124 | # examples load and are only checked if they can be resolved at all |
| 125 | ours <- called[called %in% exports | grepl("KorAP|korap", called)] |
| 126 | for (name in ours) { |
| 127 | expect_true( |
| 128 | name %in% exports, |
| 129 | label = paste0("Function '", name, "()' used in the Readme is exported") |
| 130 | ) |
| 131 | } |
| 132 | |
| 133 | # functions of the packages the Readme itself loads count as resolvable |
| 134 | declared <- unique(vapply( |
| 135 | Filter( |
| 136 | function(call) { |
| 137 | is.name(call[[1]]) && |
| 138 | as.character(call[[1]]) %in% c("library", "require") && length(call) > 1 |
| 139 | }, |
| 140 | readmeCalls() |
| 141 | ), |
| 142 | function(call) as.character(call[[2]]), |
| 143 | character(1) |
| 144 | )) |
| 145 | installed <- declared[vapply(declared, requireNamespace, logical(1), quietly = TRUE)] |
| 146 | skip_if_not( |
| 147 | length(installed) == length(declared), |
| 148 | paste0( |
| 149 | "Readme loads packages that are not installed here: ", |
| 150 | paste(setdiff(declared, installed), collapse = ", ") |
| 151 | ) |
| 152 | ) |
| 153 | fromDeclared <- unlist(lapply(installed, getNamespaceExports)) |
| 154 | |
| 155 | unknown <- called[!vapply(called, exists, logical(1)) & !(called %in% fromDeclared)] |
| 156 | expect_equal( |
| 157 | unknown, character(0), |
| 158 | label = paste0( |
| 159 | "Functions used in the Readme that cannot be resolved: ", |
| 160 | paste(unknown, collapse = ", ") |
| 161 | ) |
| 162 | ) |
| 163 | }) |
| 164 | |
| 165 | test_that("every argument named in Readme calls exists in the function", { |
| 166 | skip_without_readme() |
| 167 | |
| 168 | exports <- getNamespaceExports("RKorAPClient") |
| 169 | checked <- 0 |
| 170 | |
| 171 | for (call in readmeCalls()) { |
| 172 | if (!is.name(call[[1]])) next |
| 173 | name <- as.character(call[[1]]) |
| 174 | if (!(name %in% exports)) next |
| 175 | |
| 176 | parameters <- packageFunctionParameters(name) |
| 177 | if (is.null(parameters) || "..." %in% parameters) { |
| 178 | # a function taking ... accepts anything, so only known ones are of use |
| 179 | parameters <- c(parameters, names(call)[-1]) |
| 180 | } |
| 181 | |
| 182 | given <- names(call)[-1] |
| 183 | given <- given[nzchar(given)] |
| 184 | for (argument in given) { |
| 185 | checked <- checked + 1 |
| 186 | expect_true( |
| 187 | argument %in% parameters, |
| 188 | label = paste0("Argument '", argument, "' of ", name, "(), as used in the Readme,") |
| 189 | ) |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | expect_gt(checked, 0) |
| 194 | }) |
| 195 | |
| 196 | test_that("versions mentioned in the Readme have a NEWS.md section", { |
| 197 | skip_without_readme() |
| 198 | skip_if_not(!is.null(newsPath()), "NEWS.md not found") |
| 199 | |
| 200 | readme <- paste(readLines(readmePath(), warn = FALSE), collapse = "\n") |
| 201 | news <- readLines(newsPath(), warn = FALSE) |
| 202 | |
| 203 | claimed <- unique(regmatches( |
| 204 | readme, |
| 205 | gregexpr("RKorAPClient [0-9]+\\.[0-9]+[0-9.]*", readme) |
| 206 | )[[1]]) |
| 207 | claimed <- sub("^RKorAPClient ", "", claimed) |
| 208 | |
| 209 | for (version in claimed) { |
| 210 | expect_true( |
| 211 | any(grepl(paste0("^# .*(^| )", gsub(".", "\\.", version, fixed = TRUE), "( |$)"), news)), |
| 212 | label = paste0( |
| 213 | "Version ", version, ", mentioned in the Readme, has a NEWS.md section, and thus" |
| 214 | ) |
| 215 | ) |
| 216 | } |
| 217 | }) |