# Tests for lint_define() — reference integrity of a Define-XML document. # # The organising idea: every finding this reports is invisible to # validate_define(). Several tests therefore assert BOTH — the schema says the # document is fine, and the lint says it is not. That pairing is the whole # argument for the function existing. # # Mutations are text-level surgery on the serialised file, with the anchor's # uniqueness asserted, so an edit cannot silently land somewhere else and # certify the wrong thing. skip_if_not_installed("xml2") minimal <- function() { p <- system.file("extdata", "define-minimal.xml", package = "artoo") skip_if(!nzchar(p), "bundled minimal define is unavailable") p } fixture <- function(name) { p <- testthat::test_path("fixtures", name) skip_if(!file.exists(p), paste(name, "fixture is unavailable")) p } # Replace `old` with `new` in a copy of `path`, asserting `old` occurs exactly # `n` times first. Returns the new path. edit_xml <- function(path, old, new, n = 1L) { txt <- readLines(path, warn = FALSE) hits <- sum(vapply( txt, function(l) lengths(regmatches(l, gregexpr(old, l, fixed = TRUE))), integer(1) )) expect_identical(hits, n) out <- file.path( withr::local_tempdir(.local_envir = parent.frame()), "edited.xml" ) writeLines(gsub(old, new, txt, fixed = TRUE), out) out } checks_of <- function(report) sort(unique(report@findings$check)) # ---- the gate: clean documents stay clean ------------------------------- test_that("the bundled minimal document has no reference problems", { report <- lint_define(minimal()) expect_s3_class(report, "artoo::artoo_check") expect_identical(nrow(report@findings), 0L) expect_gt(report@summary$n_definitions, 0L) expect_gt(report@summary$n_references, 0L) }) test_that("both official Define-XML 2.0 examples are clean", { for (f in c("define20-sdtm.xml", "define20-adam.xml")) { report <- lint_define(fixture(f)) expect_identical(nrow(report@findings), 0L, info = f) } }) test_that("the official 2.1 examples report only their one real defect", { # Not zero: both 2.1 examples define six def:Standard entries and reference # only five, so STD.5 is genuinely unreferenced. This is a true positive in # CDISC's own published example -- Pinnacle 21's the unreferenced-definition rule flags it too -- so # the expectation pins it rather than suppressing it. If this count ever # moves, either the fixture changed or the lint gained a false positive. for (f in c("define21-sdtm.xml", "define21-adam.xml")) { report <- lint_define(fixture(f)) expect_true("define_orphan_standard" %in% report@findings$check, info = f) expect_match( report@findings$message[ report@findings$check == "define_orphan_standard" ], "STD\\.5", info = f ) } # The SDTM example carries a second true positive of its own: SUPPVS is # flagged def:HasNoData with no comment explaining it, which is what # Pinnacle 21 the empty-dataset comment rule asks for. Pinned rather than suppressed, for the same # reason as the orphan standard. sdtm <- lint_define(fixture("define21-sdtm.xml"))@findings expect_setequal( sdtm$check, c("define_orphan_standard", "define_no_data_uncommented") ) expect_match( sdtm$message[sdtm$check == "define_no_data_uncommented"], "SUPPVS" ) expect_identical( nrow(lint_define(fixture("define21-adam.xml"))@findings), 1L ) }) # ---- dangling references ------------------------------------------------ test_that("a dangling variable reference is caught, though the schema passes", { bad <- edit_xml(minimal(), 'ItemOID="IT.DM.SEX"', 'ItemOID="IT.DM.NOPE"') expect_true(validate_define(bad)@summary$valid) report <- lint_define(bad) expect_true("define_dangling_item" %in% checks_of(report)) expect_identical( report@findings$severity[report@findings$check == "define_dangling_item"], "error" ) expect_match( report@findings$message[report@findings$check == "define_dangling_item"], "IT.DM.NOPE" ) }) test_that("a dangling codelist reference is caught", { bad <- edit_xml(minimal(), 'CodeListOID="CL.SEX"', 'CodeListOID="CL.NOPE"') expect_true(validate_define(bad)@summary$valid) expect_true("define_dangling_codelist" %in% checks_of(lint_define(bad))) }) test_that("a dangling comment reference is caught", { bad <- edit_xml( minimal(), 'def:CommentOID="COM.SEX"', 'def:CommentOID="COM.NOPE"' ) expect_true("define_dangling_comment" %in% checks_of(lint_define(bad))) }) test_that("a dangling standard reference is caught", { bad <- edit_xml( minimal(), 'def:StandardOID="STD.1"', 'def:StandardOID="STD.NOPE"' ) expect_true("define_dangling_standard" %in% checks_of(lint_define(bad))) }) test_that("a dangling archive location gets its own finding, not a leaf one", { # An ItemGroupDef pointing at a missing leaf is a different problem from a # DocumentRef doing so -- one loses the dataset file, the other a PDF -- so # they carry different condition ids. bad <- edit_xml( minimal(), 'def:ArchiveLocationID="LF.DM"', 'def:ArchiveLocationID="LF.NOPE"' ) found <- checks_of(lint_define(bad)) expect_true("define_dangling_archive_location" %in% found) expect_false("define_dangling_leaf" %in% found) }) test_that("a dangling value list reference is caught", { src <- fixture("define21-sdtm.xml") txt <- readLines(src, warn = FALSE) hit <- grep("', "") report <- lint_define(bad) rows <- report@findings[report@findings$check == "define_orphan_codelist", ] expect_identical(nrow(rows), 1L) expect_identical(rows$severity, "warning") }) test_that("an orphaned comment is reported", { bad <- edit_xml(minimal(), ' def:CommentOID="COM.SEX"', "") expect_true("define_orphan_comment" %in% checks_of(lint_define(bad))) }) test_that("an orphaned document leaf is reported", { bad <- edit_xml(minimal(), ' def:ArchiveLocationID="LF.DM"', "") expect_true("define_orphan_leaf" %in% checks_of(lint_define(bad))) }) test_that("an orphaned variable is reported", { bad <- edit_xml( minimal(), '', "" ) expect_true("define_orphan_item" %in% checks_of(lint_define(bad))) }) # ---- the carve-outs that prevent false positives ------------------------ test_that("a dictionary-backed codelist is exempt from the orphan check", { # An ExternalCodeList (MedDRA, WHODrug, ISO 3166) names a dictionary rather # than enumerating terms, so nothing points at it with a CodeListRef and it # is not an orphan. Without this exemption every real AE or CM define # reports a spurious finding. txt <- readLines(minimal(), warn = FALSE) anchor <- grep("", txt, fixed = TRUE) expect_length(anchor, 1L) injected <- append( txt, paste0( ' ', '' ), after = anchor - 1L ) out <- file.path(withr::local_tempdir(), "external.xml") writeLines(injected, out) report <- lint_define(out) expect_identical(report@summary$n_external_codelists, 1L) expect_false("define_orphan_codelist" %in% checks_of(report)) }) test_that("a codelist reached through RoleCodeListOID counts as referenced", { # It is referenced, just not through CodeListRef. Swapping the reference # form must not turn the codelist into an orphan. bad <- edit_xml( minimal(), '', "" ) txt <- readLines(bad, warn = FALSE) hit <- grep('$", ' RoleCodeListOID="CL.SEX"/>', txt[hit]) out <- file.path(withr::local_tempdir(), "role.xml") writeLines(txt, out) expect_false("define_orphan_codelist" %in% checks_of(lint_define(out))) }) # ---- Origin inheritance, both directions -------------------------------- test_that("a variable with no Origin anywhere is reported", { bad <- edit_xml(minimal(), '', "") report <- lint_define(bad) expect_true("define_missing_origin" %in% checks_of(report)) expect_match( report@findings$message[report@findings$check == "define_missing_origin"], "IT.DM.USUBJID" ) }) test_that("a parent variable inherits Origin from its value-level items", { # define21-sdtm.xml's LBORRES carries no Origin of its own; its value-level # items supply one each. Reporting that parent would be a false positive, # and it is exactly the mistake the first implementation made. report <- lint_define(fixture("define21-sdtm.xml")) expect_false("define_missing_origin" %in% checks_of(report)) }) # ---- error paths -------------------------------------------------------- test_that("a non-XML file is refused", { bad <- file.path(withr::local_tempdir(), "junk.xml") writeLines("not xml <<<", bad) expect_error(lint_define(bad), class = "artoo_error_input") }) test_that("a document with no MetaDataVersion is refused", { other <- file.path(withr::local_tempdir(), "no-mdv.xml") writeLines("", other) expect_error(lint_define(other), class = "artoo_error_input") expect_snapshot( lint_define(other), error = TRUE, transform = function(x) { gsub("'[^']*[/\\\\]([^/\\\\']+\\.xml)'", "'/\\1'", x) } ) }) test_that("a bad path argument is refused", { expect_error(lint_define(123), class = "artoo_error_input") }) test_that("a missing file says so, rather than reporting unparseable XML", { expect_error( lint_define(file.path(withr::local_tempdir(), "absent.xml")), class = "artoo_error_input" ) }) test_that("a MetaDataVersion with no definitions or references is handled", { # The degenerate document: structurally a define, semantically empty. Every # collector must return its typed empty shape rather than failing. bare <- file.path(withr::local_tempdir(), "bare.xml") writeLines( paste0( '', "" ), bare ) report <- lint_define(bare) expect_identical(nrow(report@findings), 0L) expect_identical(report@summary$n_definitions, 0L) expect_identical(report@summary$n_references, 0L) expect_identical(report@summary$n_external_codelists, 0L) }) test_that("the printed reports name what they actually checked", { # A Define-XML report must not render the spec-check header: "Datasets: 0 # Variables: 0" is false for a define document, and the fields that matter # (version, verdict, reference counts) would never be shown. expect_snapshot(print(validate_define(minimal()))) expect_snapshot(print(lint_define(minimal()))) }) test_that("the lint report shows the external-codelist exemption count", { txt <- readLines(minimal(), warn = FALSE) anchor <- grep("", txt, fixed = TRUE) injected <- append( txt, paste0( ' ', '' ), after = anchor - 1L ) out <- file.path(withr::local_tempdir(), "external-report.xml") writeLines(injected, out) expect_output(print(lint_define(out)), "External codelists") }) test_that("a dangling ItemOID inside a value list does not crash the lint (#blocker)", { # Regression. `has_origin[[k]]` threw a bare `subscript out of bounds` when a # value list referenced an ItemOID no ItemDef defines -- which is herald bug # (b), the exact defect this lint exists to report. The gate crashed # precisely on the failure it was built to catch, and later phases lean on # lint_define() as the gate over writer output. txt <- readLines(minimal(), warn = FALSE) anchor <- grep("", txt, fixed = TRUE) expect_length(anchor, 1L) injected <- append( txt, c( ' ', ' ', " " ), after = anchor - 1L ) # Give the parent a value list AND remove its own Origin, so the inheritance # walk has to resolve the dangling child. injected <- sub( '', '', injected, fixed = TRUE ) out <- file.path(withr::local_tempdir(), "dangling-vl.xml") writeLines(injected, out) report <- expect_no_error(lint_define(out)) found <- checks_of(report) expect_true("define_dangling_item" %in% found) expect_true("define_missing_origin" %in% found) }) test_that("a comment referenced only from MetaDataVersion is not an orphan", { # def:CommentOID is legal on MetaDataVersion itself, and an XPath of ".//*" # excludes the context node. Missing it produced a false orphan here, and a # false negative for the dangling direction below. txt <- readLines(minimal(), warn = FALSE) hit <- grep('def:DefineVersion="2.1.0">', txt, fixed = TRUE) expect_length(hit, 1L) # Drop the existing reference FIRST, then add the MetaDataVersion one -- # doing it the other way round strips the reference straight back off. txt <- sub(' def:CommentOID="COM.SEX"', "", txt, fixed = TRUE) txt[hit] <- sub( 'def:DefineVersion="2.1.0">', 'def:DefineVersion="2.1.0" def:CommentOID="COM.SEX">', txt[hit], fixed = TRUE ) expect_true(any(grepl('def:CommentOID="COM.SEX"', txt, fixed = TRUE))) out <- file.path(withr::local_tempdir(), "mdv-comment.xml") writeLines(txt, out) expect_false("define_orphan_comment" %in% checks_of(lint_define(out))) }) test_that("a dangling comment reference on MetaDataVersion is caught", { txt <- readLines(minimal(), warn = FALSE) hit <- grep('def:DefineVersion="2.1.0">', txt, fixed = TRUE) txt[hit] <- sub( 'def:DefineVersion="2.1.0">', 'def:DefineVersion="2.1.0" def:CommentOID="COM.NOPE">', txt[hit], fixed = TRUE ) out <- file.path(withr::local_tempdir(), "mdv-dangling.xml") writeLines(txt, out) expect_true("define_dangling_comment" %in% checks_of(lint_define(out))) }) test_that("the two gates that only lint can see (#p9-review)", { skip_if_not_installed("xml2") # An unconditional value-level item and an uncommented empty dataset are # both invisible to the schema (well-formed, and an optional attribute) and # to the reference checks (nothing to dangle). Without these rules, phase # 10's acceptance gate would certify the exact artefacts it exists to catch. path <- withr::local_tempfile(fileext = ".xml") writeLines( c( '', '', ' ', " SS", " S", ' ', ' ', ' ', " ", ' ', ' ', " ", ' ', " " ), path ) checks <- lint_define(path)@findings$check expect_true("define_unconditional_value" %in% checks) expect_true("define_no_data_uncommented" %in% checks) })