blob: fcea953f0dae27146203c3edbe3aa9df6a64e980 [file] [log] [blame]
Marc Kupietzefec8632026-09-03 13:13:37 +02001# 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
10readmePath <- 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
19skip_without_readme <- function() {
20 skip_if_not(!is.null(readmePath()), "Readme.md not found (checking a built package?)")
21}
22
23newsPath <- 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
33readmeRCodeBlocks <- 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
49callsIn <- 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
64readmeCalls <- 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.
77packageFunctionParameters <- 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
93test_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
112test_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
165test_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
196test_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})