blob: fcea953f0dae27146203c3edbe3aa9df6a64e980 [file] [log] [blame]
# Consistency checks of Readme.md against the package. These do not query
# anything and run in milliseconds, unlike the documentation prompting tests in
# test-readme-against-llm.R. They guard against the Readme drifting away from
# the package, which misleads readers and, since generated code is copied from
# it verbatim, LLMs as well.
#
# Readme.md is in .Rbuildignore, so it is absent from the built package and
# these tests skip when checking a tarball.
readmePath <- function() {
for (path in c("Readme.md", "../Readme.md", "../../Readme.md")) {
if (file.exists(path)) {
return(path)
}
}
NULL
}
skip_without_readme <- function() {
skip_if_not(!is.null(readmePath()), "Readme.md not found (checking a built package?)")
}
newsPath <- function() {
for (path in c("NEWS.md", "../NEWS.md", "../../NEWS.md")) {
if (file.exists(path)) {
return(path)
}
}
NULL
}
# All R code blocks of the Readme, as character vectors of their lines
readmeRCodeBlocks <- function() {
readme <- readLines(readmePath(), warn = FALSE)
starts <- grep("^```[rR]$", readme)
ends <- grep("^```$", readme)
blocks <- list()
for (start in starts) {
end <- ends[ends > start][1]
if (!is.na(end) && end > start + 1) {
blocks[[length(blocks) + 1]] <- readme[(start + 1):(end - 1)]
}
}
blocks
}
# Every call in an expression, as a list of calls
callsIn <- function(expression) {
found <- list()
walk <- function(x) {
if (is.call(x)) {
found[[length(found) + 1]] <<- x
for (i in seq_along(x)) {
element <- tryCatch(x[[i]], error = function(e) NULL)
if (!is.null(element)) walk(element)
}
}
}
walk(expression)
found
}
readmeCalls <- function() {
calls <- list()
for (block in readmeRCodeBlocks()) {
parsed <- tryCatch(parse(text = paste(block, collapse = "\n")), error = function(e) NULL)
if (is.null(parsed)) next
for (expression in parsed) calls <- c(calls, callsIn(expression))
}
calls
}
# Parameter names of a function of this package. S4 methods whose signature
# differs from the generic are wrapped in a .local function by methods::
# setMethod(), whose formals are the ones actually documented and called.
packageFunctionParameters <- function(name) {
method <- tryCatch(getMethod(name, "KorAPConnection"), error = function(e) NULL)
if (is.null(method)) {
plain <- tryCatch(get(name, envir = asNamespace("RKorAPClient")), error = function(e) NULL)
return(if (is.function(plain)) names(formals(plain)) else NULL)
}
body <- body(method)
isRematched <- is.call(body) && identical(body[[1]], as.name("{")) &&
length(body) > 1 && is.call(body[[2]]) &&
identical(body[[2]][[1]], as.name("<-")) &&
identical(body[[2]][[2]], as.name(".local"))
names(formals(if (isRematched) eval(body[[2]][[3]]) else method))
}
test_that("all R code blocks in the Readme parse", {
skip_without_readme()
blocks <- readmeRCodeBlocks()
expect_gt(length(blocks), 0)
for (block in blocks) {
code <- paste(block, collapse = "\n")
parsed <- tryCatch(parse(text = code), error = function(e) e)
expect_false(
inherits(parsed, "error"),
label = paste0(
"Readme code block starting with '", block[1], "' does not parse: ",
if (inherits(parsed, "error")) conditionMessage(parsed) else ""
)
)
}
})
test_that("every function called in the Readme exists", {
skip_without_readme()
exports <- getNamespaceExports("RKorAPClient")
called <- unique(vapply(
Filter(function(call) is.name(call[[1]]), readmeCalls()),
function(call) as.character(call[[1]]),
character(1)
))
expect_gt(length(called), 0)
# functions of this package must exist, others may come from any package the
# examples load and are only checked if they can be resolved at all
ours <- called[called %in% exports | grepl("KorAP|korap", called)]
for (name in ours) {
expect_true(
name %in% exports,
label = paste0("Function '", name, "()' used in the Readme is exported")
)
}
# functions of the packages the Readme itself loads count as resolvable
declared <- unique(vapply(
Filter(
function(call) {
is.name(call[[1]]) &&
as.character(call[[1]]) %in% c("library", "require") && length(call) > 1
},
readmeCalls()
),
function(call) as.character(call[[2]]),
character(1)
))
installed <- declared[vapply(declared, requireNamespace, logical(1), quietly = TRUE)]
skip_if_not(
length(installed) == length(declared),
paste0(
"Readme loads packages that are not installed here: ",
paste(setdiff(declared, installed), collapse = ", ")
)
)
fromDeclared <- unlist(lapply(installed, getNamespaceExports))
unknown <- called[!vapply(called, exists, logical(1)) & !(called %in% fromDeclared)]
expect_equal(
unknown, character(0),
label = paste0(
"Functions used in the Readme that cannot be resolved: ",
paste(unknown, collapse = ", ")
)
)
})
test_that("every argument named in Readme calls exists in the function", {
skip_without_readme()
exports <- getNamespaceExports("RKorAPClient")
checked <- 0
for (call in readmeCalls()) {
if (!is.name(call[[1]])) next
name <- as.character(call[[1]])
if (!(name %in% exports)) next
parameters <- packageFunctionParameters(name)
if (is.null(parameters) || "..." %in% parameters) {
# a function taking ... accepts anything, so only known ones are of use
parameters <- c(parameters, names(call)[-1])
}
given <- names(call)[-1]
given <- given[nzchar(given)]
for (argument in given) {
checked <- checked + 1
expect_true(
argument %in% parameters,
label = paste0("Argument '", argument, "' of ", name, "(), as used in the Readme,")
)
}
}
expect_gt(checked, 0)
})
test_that("versions mentioned in the Readme have a NEWS.md section", {
skip_without_readme()
skip_if_not(!is.null(newsPath()), "NEWS.md not found")
readme <- paste(readLines(readmePath(), warn = FALSE), collapse = "\n")
news <- readLines(newsPath(), warn = FALSE)
claimed <- unique(regmatches(
readme,
gregexpr("RKorAPClient [0-9]+\\.[0-9]+[0-9.]*", readme)
)[[1]])
claimed <- sub("^RKorAPClient ", "", claimed)
for (version in claimed) {
expect_true(
any(grepl(paste0("^# .*(^| )", gsub(".", "\\.", version, fixed = TRUE), "( |$)"), news)),
label = paste0(
"Version ", version, ", mentioned in the Readme, has a NEWS.md section, and thus"
)
)
}
})