# Shared fixture: 3-snapshot trail with join, filter, and custom diagnostics
make_export_trail <- function() {
trail <- audit_trail("export_test")
orders <- data.frame(id = 1:4, amount = c(100, 200, 300, 400))
lookup <- data.frame(id = c(2L, 3L, 5L), name = c("A", "B", "C"),
stringsAsFactors = FALSE)
orders |>
audit_tap(trail, "raw") |>
left_join_tap(lookup, by = "id", .trail = trail, .label = "joined") |>
dplyr::filter(amount > 100) |>
audit_tap(trail, "filtered", .fns = list(n = nrow))
trail
}
# ── trail_to_list ─────────────────────────────────────────────────────────────
test_that("trail_to_list returns a plain list with correct top-level keys", {
lst <- trail_to_list(make_export_trail())
expect_type(lst, "list")
expect_named(lst, c("name", "created_at", "n_snapshots", "snapshots"))
expect_equal(lst$name, "export_test")
expect_equal(lst$n_snapshots, 3L)
expect_type(lst$created_at, "character")
expect_true(grepl("T", lst$created_at))
})
test_that("trail_to_list snapshots are named by label", {
lst <- trail_to_list(make_export_trail())
expect_named(lst$snapshots, c("raw", "joined", "filtered"))
})
test_that("trail_to_list converts schema data.frame to list of rows", {
lst <- trail_to_list(make_export_trail())
schema <- lst$snapshots$raw$schema
expect_type(schema, "list")
expect_true(is.list(schema[[1]]))
expect_true("column" %in% names(schema[[1]]))
expect_true("type" %in% names(schema[[1]]))
expect_true("n_na" %in% names(schema[[1]]))
})
test_that("trail_to_list converts POSIXct timestamps to ISO 8601 strings", {
lst <- trail_to_list(make_export_trail())
expect_type(lst$snapshots$raw$timestamp, "character")
expect_true(grepl("T", lst$snapshots$raw$timestamp))
})
test_that("trail_to_list handles NULL numeric_summary (character-only data)", {
trail <- audit_trail("no_num")
data.frame(x = letters[1:3], stringsAsFactors = FALSE) |>
audit_tap(trail, "text_only")
lst <- trail_to_list(trail)
expect_null(lst$snapshots$text_only$numeric_summary)
})
test_that("trail_to_list has NULL changes on the first snapshot", {
lst <- trail_to_list(make_export_trail())
expect_null(lst$snapshots$raw$changes)
})
test_that("trail_to_list converts type_changes in changes to list of rows", {
trail <- audit_trail("type_change")
data.frame(x = 1:3) |> audit_tap(trail, "before")
data.frame(x = as.character(1:3), stringsAsFactors = FALSE) |>
audit_tap(trail, "after")
lst <- trail_to_list(trail)
tc <- lst$snapshots$after$changes$type_changes
expect_type(tc, "list")
expect_true(is.list(tc[[1]]))
expect_true("column" %in% names(tc[[1]]))
})
test_that("trail_to_list output is JSON-serialisable", {
skip_if_not_installed("jsonlite")
lst <- trail_to_list(make_export_trail())
expect_no_error(jsonlite::toJSON(lst, auto_unbox = TRUE))
})
test_that("trail_to_list rejects non-trail input", {
expect_error(trail_to_list(list()), "audit_trail")
expect_error(trail_to_list("not a trail"), "audit_trail")
})
test_that("trail_to_list on empty trail returns empty snapshots list", {
trail <- audit_trail("empty")
lst <- trail_to_list(trail)
expect_equal(lst$n_snapshots, 0L)
expect_equal(length(lst$snapshots), 0L)
})
# ── trail_to_df ───────────────────────────────────────────────────────────────
test_that("trail_to_df returns a data.frame", {
df <- trail_to_df(make_export_trail())
expect_s3_class(df, "data.frame")
expect_false(inherits(df, "tbl_df"))
})
test_that("trail_to_df has one row per snapshot", {
df <- trail_to_df(make_export_trail())
expect_equal(nrow(df), 3L)
})
test_that("trail_to_df has the expected column names", {
df <- trail_to_df(make_export_trail())
expected <- c("index", "label", "type", "timestamp", "nrow", "ncol",
"total_nas", "all_columns", "schema", "numeric_summary",
"changes", "diagnostics", "custom", "pipeline", "controls")
expect_named(df, expected)
})
test_that("trail_to_df scalar columns have correct types", {
df <- trail_to_df(make_export_trail())
expect_type(df$index, "integer")
expect_type(df$label, "character")
expect_type(df$type, "character")
expect_s3_class(df$timestamp, "POSIXct")
expect_type(df$nrow, "integer")
expect_type(df$ncol, "integer")
expect_type(df$total_nas, "integer")
})
test_that("trail_to_df schema is a list-column of data.frames", {
df <- trail_to_df(make_export_trail())
expect_type(df$schema, "list")
expect_s3_class(df$schema[[1]], "data.frame")
})
test_that("trail_to_df changes is NULL for the first row", {
df <- trail_to_df(make_export_trail())
expect_null(df$changes[[1]])
})
test_that("trail_to_df attaches trail metadata as attributes", {
df <- trail_to_df(make_export_trail())
expect_equal(attr(df, "trail_name"), "export_test")
expect_s3_class(attr(df, "created_at"), "POSIXct")
})
test_that("trail_to_df on empty trail returns zero-row data.frame with correct columns", {
df <- trail_to_df(audit_trail("empty"))
expect_s3_class(df, "data.frame")
expect_equal(nrow(df), 0L)
expect_equal(ncol(df), 15L)
})
test_that("trail_to_df diagnostics is NULL for plain taps", {
trail <- audit_trail("plain")
mtcars |> audit_tap(trail, "step1")
df <- trail_to_df(trail)
expect_null(df$diagnostics[[1]])
})
test_that("trail_to_df rejects non-trail input", {
expect_error(trail_to_df(list()), "audit_trail")
})
# ── write_trail / read_trail — RDS ───────────────────────────────────────────
test_that("write_trail (rds) creates a file", {
tmp <- tempfile(fileext = ".rds")
on.exit(unlink(tmp))
write_trail(make_export_trail(), tmp)
expect_true(file.exists(tmp))
})
test_that("write_trail returns .trail invisibly", {
tmp <- tempfile(fileext = ".rds")
on.exit(unlink(tmp))
trail <- make_export_trail()
result <- withVisible(write_trail(trail, tmp))
expect_false(result$visible)
expect_identical(result$value, trail)
})
test_that("read_trail (rds) restores audit_trail class and structure", {
tmp <- tempfile(fileext = ".rds")
on.exit(unlink(tmp))
original <- make_export_trail()
write_trail(original, tmp)
restored <- read_trail(tmp)
expect_s3_class(restored, "audit_trail")
expect_s3_class(restored, "environment")
expect_equal(restored$name, "export_test")
expect_s3_class(restored$created_at, "POSIXct")
expect_equal(length(restored$snapshots), 3L)
expect_equal(restored$labels, c("raw", "joined", "filtered"))
})
test_that("read_trail (rds) restores audit_snap S3 class on every snapshot", {
tmp <- tempfile(fileext = ".rds")
on.exit(unlink(tmp))
write_trail(make_export_trail(), tmp)
restored <- read_trail(tmp)
for (snap in restored$snapshots) {
expect_s3_class(snap, "audit_snap")
}
})
test_that("read_trail (rds) preserves snapshot scalar fields", {
tmp <- tempfile(fileext = ".rds")
on.exit(unlink(tmp))
original <- make_export_trail()
write_trail(original, tmp)
restored <- read_trail(tmp)
orig_raw <- original$snapshots[[1]]
rest_raw <- restored$snapshots[[1]]
expect_equal(rest_raw$label, orig_raw$label)
expect_equal(rest_raw$nrow, orig_raw$nrow)
expect_equal(rest_raw$ncol, orig_raw$ncol)
expect_equal(rest_raw$total_nas, orig_raw$total_nas)
expect_equal(rest_raw$type, orig_raw$type)
})
test_that("read_trail (rds) preserves schema data.frame", {
tmp <- tempfile(fileext = ".rds")
on.exit(unlink(tmp))
original <- make_export_trail()
write_trail(original, tmp)
restored <- read_trail(tmp)
expect_equal(restored$snapshots[[1]]$schema, original$snapshots[[1]]$schema)
})
test_that("read_trail (rds) preserves changes list", {
tmp <- tempfile(fileext = ".rds")
on.exit(unlink(tmp))
original <- make_export_trail()
write_trail(original, tmp)
restored <- read_trail(tmp)
expect_null(restored$snapshots[[1]]$changes)
expect_equal(
restored$snapshots[[2]]$changes$row_delta,
original$snapshots[[2]]$changes$row_delta
)
})
test_that("read_trail (rds) preserves custom diagnostics", {
tmp <- tempfile(fileext = ".rds")
on.exit(unlink(tmp))
original <- make_export_trail()
write_trail(original, tmp)
restored <- read_trail(tmp)
# 3rd snapshot has .fns = list(n = nrow)
expect_equal(restored$snapshots[[3]]$custom$n,
original$snapshots[[3]]$custom$n)
})
test_that("read_trail auto-detects rds format from extension", {
tmp <- tempfile(fileext = ".rds")
on.exit(unlink(tmp))
write_trail(make_export_trail(), tmp)
expect_s3_class(read_trail(tmp), "audit_trail")
})
test_that("read_trail with explicit format = 'rds' works for non-.rds extension", {
tmp <- tempfile(fileext = ".dat")
on.exit(unlink(tmp))
write_trail(make_export_trail(), tmp, format = "rds")
restored <- read_trail(tmp, format = "rds")
expect_s3_class(restored, "audit_trail")
expect_equal(length(restored$snapshots), 3L)
})
# ── write_trail / read_trail — JSON ──────────────────────────────────────────
test_that("write_trail (json) creates a valid JSON file", {
skip_if_not_installed("jsonlite")
tmp <- tempfile(fileext = ".json")
on.exit(unlink(tmp))
write_trail(make_export_trail(), tmp, format = "json")
expect_true(file.exists(tmp))
expect_no_error(jsonlite::fromJSON(tmp))
})
test_that("read_trail (json) restores audit_trail class and snapshot count", {
skip_if_not_installed("jsonlite")
tmp <- tempfile(fileext = ".json")
on.exit(unlink(tmp))
write_trail(make_export_trail(), tmp, format = "json")
restored <- read_trail(tmp)
expect_s3_class(restored, "audit_trail")
expect_equal(restored$name, "export_test")
expect_equal(length(restored$snapshots), 3L)
expect_equal(restored$labels, c("raw", "joined", "filtered"))
})
test_that("read_trail (json) restores audit_snap S3 class on every snapshot", {
skip_if_not_installed("jsonlite")
tmp <- tempfile(fileext = ".json")
on.exit(unlink(tmp))
write_trail(make_export_trail(), tmp, format = "json")
restored <- read_trail(tmp)
for (snap in restored$snapshots) {
expect_s3_class(snap, "audit_snap")
}
})
test_that("read_trail (json) restores scalar fields with correct types", {
skip_if_not_installed("jsonlite")
tmp <- tempfile(fileext = ".json")
on.exit(unlink(tmp))
write_trail(make_export_trail(), tmp, format = "json")
restored <- read_trail(tmp)
snap <- restored$snapshots[[1]]
expect_type(snap$index, "integer")
expect_type(snap$nrow, "integer")
expect_type(snap$ncol, "integer")
expect_type(snap$total_nas, "integer")
expect_s3_class(snap$timestamp, "POSIXct")
})
test_that("read_trail (json) restores schema as data.frame with correct column names", {
skip_if_not_installed("jsonlite")
tmp <- tempfile(fileext = ".json")
on.exit(unlink(tmp))
write_trail(make_export_trail(), tmp, format = "json")
restored <- read_trail(tmp)
schema <- restored$snapshots[[1]]$schema
expect_s3_class(schema, "data.frame")
expect_named(schema, c("column", "type", "n_na"))
expect_type(schema$n_na, "integer")
})
test_that("read_trail (json) restores type_changes as data.frame", {
skip_if_not_installed("jsonlite")
trail <- audit_trail("type_change_json")
data.frame(x = 1:3) |> audit_tap(trail, "before")
data.frame(x = as.character(1:3), stringsAsFactors = FALSE) |>
audit_tap(trail, "after")
tmp <- tempfile(fileext = ".json")
on.exit(unlink(tmp))
write_trail(trail, tmp, format = "json")
restored <- read_trail(tmp)
tc <- restored$snapshots[[2]]$changes$type_changes
expect_s3_class(tc, "data.frame")
expect_named(tc, c("column", "from", "to"))
})
test_that("read_trail (json) restores NULL changes on first snapshot", {
skip_if_not_installed("jsonlite")
tmp <- tempfile(fileext = ".json")
on.exit(unlink(tmp))
write_trail(make_export_trail(), tmp, format = "json")
restored <- read_trail(tmp)
expect_null(restored$snapshots[[1]]$changes)
})
test_that("read_trail (json) restores changes integer deltas correctly", {
skip_if_not_installed("jsonlite")
tmp <- tempfile(fileext = ".json")
on.exit(unlink(tmp))
original <- make_export_trail()
write_trail(original, tmp, format = "json")
restored <- read_trail(tmp)
orig_delta <- original$snapshots[[2]]$changes$row_delta
rest_delta <- restored$snapshots[[2]]$changes$row_delta
expect_equal(rest_delta, orig_delta)
expect_type(rest_delta, "integer")
})
test_that("read_trail auto-detects json format from extension", {
skip_if_not_installed("jsonlite")
tmp <- tempfile(fileext = ".json")
on.exit(unlink(tmp))
write_trail(make_export_trail(), tmp, format = "json")
expect_s3_class(read_trail(tmp), "audit_trail")
})
test_that("read_trail (json) restores numeric_summary values as double", {
skip_if_not_installed("jsonlite")
tmp <- tempfile(fileext = ".json")
on.exit(unlink(tmp))
original <- make_export_trail()
write_trail(original, tmp, format = "json")
restored <- read_trail(tmp)
orig_ns <- original$snapshots[[1]]$numeric_summary
rest_ns <- restored$snapshots[[1]]$numeric_summary
expect_s3_class(rest_ns, "data.frame")
expect_equal(rest_ns$min, orig_ns$min)
expect_equal(rest_ns$median, orig_ns$median)
expect_type(rest_ns$min, "double")
expect_type(rest_ns$median, "double")
expect_type(rest_ns$max, "double")
})
test_that("read_trail (json) restores custom diagnostics", {
skip_if_not_installed("jsonlite")
tmp <- tempfile(fileext = ".json")
on.exit(unlink(tmp))
original <- make_export_trail()
write_trail(original, tmp, format = "json")
restored <- read_trail(tmp)
# 3rd snapshot has .fns = list(n = nrow)
expect_equal(restored$snapshots[[3]]$custom$n,
original$snapshots[[3]]$custom$n)
})
test_that(".parse_posixct returns a POSIXct-classed NA for NULL input", {
result <- tidyaudit:::.parse_posixct(NULL)
expect_s3_class(result, "POSIXct")
expect_true(is.na(result))
})
test_that("read_trail (json) restores schema as 0-row data.frame for zero-column input", {
skip_if_not_installed("jsonlite")
trail <- audit_trail("zero_cols")
data.frame(row.names = 1:3) |> audit_tap(trail, "empty")
tmp <- tempfile(fileext = ".json")
on.exit(unlink(tmp))
write_trail(trail, tmp, format = "json")
restored <- read_trail(tmp)
schema <- restored$snapshots[[1]]$schema
expect_s3_class(schema, "data.frame")
expect_named(schema, c("column", "type", "n_na"))
expect_equal(nrow(schema), 0L)
})
# ── Error handling ────────────────────────────────────────────────────────────
test_that("write_trail errors on non-trail input", {
expect_error(write_trail(list(), tempfile()), "audit_trail")
})
test_that("write_trail errors on invalid file argument", {
trail <- make_export_trail()
expect_error(write_trail(trail, NA_character_), "file")
expect_error(write_trail(trail, c("a", "b")), "file")
})
test_that("read_trail errors when file does not exist", {
expect_error(read_trail("/nonexistent/path/trail.rds"), "not found")
})
test_that("read_trail errors on unknown extension without explicit format", {
tmp <- tempfile(fileext = ".csv")
file.create(tmp)
on.exit(unlink(tmp))
expect_error(read_trail(tmp), "infer")
})
test_that("read_trail errors on invalid file argument", {
expect_error(read_trail(NA_character_), "file")
})
# ── audit_export ─────────────────────────────────────────────────────────────
test_that("audit_export creates an HTML file", {
skip_if_not_installed("jsonlite")
tmp <- tempfile(fileext = ".html")
on.exit(unlink(tmp))
trail <- make_export_trail()
audit_export(trail, file = tmp)
expect_true(file.exists(tmp))
})
test_that("audit_export output is valid HTML containing the trail JSON", {
skip_if_not_installed("jsonlite")
tmp <- tempfile(fileext = ".html")
on.exit(unlink(tmp))
trail <- make_export_trail()
audit_export(trail, file = tmp)
content <- paste(readLines(tmp), collapse = "\n")
expect_true(grepl("", content, fixed = TRUE))
expect_true(grepl("TRAIL_DATA", content, fixed = TRUE))
expect_true(grepl("export_test", content, fixed = TRUE))
})
test_that("audit_export returns the file path invisibly", {
skip_if_not_installed("jsonlite")
tmp <- tempfile(fileext = ".html")
on.exit(unlink(tmp))
trail <- make_export_trail()
result <- withVisible(audit_export(trail, file = tmp))
expect_false(result$visible)
expect_equal(result$value, tmp)
})
test_that("audit_export embeds all snapshot labels in the output", {
skip_if_not_installed("jsonlite")
tmp <- tempfile(fileext = ".html")
on.exit(unlink(tmp))
trail <- make_export_trail()
audit_export(trail, file = tmp)
content <- paste(readLines(tmp), collapse = "\n")
expect_true(grepl('"raw"', content, fixed = TRUE))
expect_true(grepl('"joined"', content, fixed = TRUE))
expect_true(grepl('"filtered"', content, fixed = TRUE))
})
test_that("audit_export errors on non-trail input", {
expect_error(audit_export(list()), "audit_trail")
})
test_that("audit_export errors on invalid file argument", {
skip_if_not_installed("jsonlite")
trail <- make_export_trail()
expect_error(audit_export(trail, file = NA_character_), "file")
expect_error(audit_export(trail, file = c("a.html", "b.html")), "file")
})
test_that("audit_export works with empty trail", {
skip_if_not_installed("jsonlite")
tmp <- tempfile(fileext = ".html")
on.exit(unlink(tmp))
trail <- audit_trail("empty")
audit_export(trail, file = tmp)
expect_true(file.exists(tmp))
content <- paste(readLines(tmp), collapse = "\n")
expect_true(grepl("TRAIL_DATA", content, fixed = TRUE))
})
test_that("audit_export embeds join diagnostics", {
skip_if_not_installed("jsonlite")
tmp <- tempfile(fileext = ".html")
on.exit(unlink(tmp))
trail <- make_export_trail()
audit_export(trail, file = tmp)
content <- paste(readLines(tmp), collapse = "\n")
expect_true(grepl("match_rate", content, fixed = TRUE))
})
test_that("audit_export embeds filter diagnostics when present", {
skip_if_not_installed("jsonlite")
tmp <- tempfile(fileext = ".html")
on.exit(unlink(tmp))
trail <- audit_trail("filter_test")
data.frame(id = 1:10, amount = 1:10 * 100) |>
audit_tap(trail, "raw") |>
filter_tap(amount > 300, .trail = trail, .label = "filtered")
audit_export(trail, file = tmp)
content <- paste(readLines(tmp), collapse = "\n")
expect_true(grepl("pct_dropped", content, fixed = TRUE))
})
test_that("audit_export with file=NULL creates a temp file", {
skip_if_not_installed("jsonlite")
trail <- make_export_trail()
# Stub browseURL to prevent opening a browser in tests
local_mocked_bindings(browseURL = function(...) invisible(NULL), .package = "utils")
result <- audit_export(trail)
on.exit(unlink(result))
expect_true(file.exists(result))
expect_true(grepl("\\.html$", result))
})
test_that("audit_export escapes in trail content", {
skip_if_not_installed("jsonlite")
tmp <- tempfile(fileext = ".html")
on.exit(unlink(tmp))
trail <- audit_trail("")
mtcars |> audit_tap(trail, "bad")
audit_export(trail, file = tmp)
content <- paste(readLines(tmp), collapse = "\n")
# A raw inside the JSON blob would break the HTML occurrences — there should be exactly 1 (the real closing tag).
expect_equal(length(gregexpr("", content, fixed = TRUE)[[1L]]), 1L)
})
# ---------------------------------------------------------------------------
# Snapshot controls serialization
# ---------------------------------------------------------------------------
test_that("JSON round-trip preserves controls", {
skip_if_not_installed("jsonlite")
trail <- audit_trail("controls_json")
mtcars |> audit_tap(trail, "raw", .numeric_summary = FALSE,
.cols_include = c("mpg", "cyl"))
tmp <- tempfile(fileext = ".json")
on.exit(unlink(tmp))
write_trail(trail, tmp, format = "json")
restored <- read_trail(tmp, format = "json")
snap <- restored$snapshots[[1]]
expect_null(snap$numeric_summary)
expect_equal(snap$schema$column, c("mpg", "cyl"))
expect_false(is.null(snap$controls))
# jsonlite may deserialize character vectors as lists; compare via unlist
expect_equal(unlist(snap$controls$cols_include), c("mpg", "cyl"))
expect_false(snap$controls$numeric_summary)
})
test_that("RDS round-trip preserves controls", {
trail <- audit_trail("controls_rds")
mtcars |> audit_tap(trail, "raw", .cols_exclude = c("disp", "hp"))
tmp <- tempfile(fileext = ".rds")
on.exit(unlink(tmp))
write_trail(trail, tmp, format = "rds")
restored <- read_trail(tmp, format = "rds")
snap <- restored$snapshots[[1]]
expect_false("disp" %in% snap$schema$column)
expect_false("hp" %in% snap$schema$column)
expect_equal(snap$controls$cols_exclude, c("disp", "hp"))
})
test_that("trail_to_df includes controls column", {
trail <- audit_trail("controls_df")
mtcars |> audit_tap(trail, "raw", .numeric_summary = FALSE)
df <- trail_to_df(trail)
expect_true("controls" %in% names(df))
expect_false(is.null(df$controls[[1]]))
})
# ---------------------------------------------------------------------------
# all_columns serialization
# ---------------------------------------------------------------------------
test_that("JSON round-trip preserves all_columns", {
skip_if_not_installed("jsonlite")
trail <- audit_trail("allcols_json")
mtcars |> audit_tap(trail, "raw", .cols_include = c("mpg", "cyl"))
tmp <- tempfile(fileext = ".json")
on.exit(unlink(tmp))
write_trail(trail, tmp, format = "json")
restored <- read_trail(tmp, format = "json")
snap <- restored$snapshots[[1]]
expect_equal(snap$all_columns, names(mtcars))
expect_equal(snap$schema$column, c("mpg", "cyl"))
})
test_that("RDS round-trip preserves all_columns", {
trail <- audit_trail("allcols_rds")
mtcars |> audit_tap(trail, "raw", .cols_exclude = "disp")
tmp <- tempfile(fileext = ".rds")
on.exit(unlink(tmp))
write_trail(trail, tmp, format = "rds")
restored <- read_trail(tmp, format = "rds")
snap <- restored$snapshots[[1]]
expect_equal(snap$all_columns, names(mtcars))
expect_false("disp" %in% snap$schema$column)
})
test_that("trail_to_df includes all_columns list-column", {
trail <- audit_trail("allcols_df")
mtcars |> audit_tap(trail, "raw")
df <- trail_to_df(trail)
expect_true("all_columns" %in% names(df))
expect_equal(df$all_columns[[1]], names(mtcars))
})