tests/testthat/test-coverage.R

# Unit tests for CoverageTracker (R/coverage.R)
#
# NOTE: To clean up root directory, manually remove:
#   cleanup_docs.sh, CLEANUP_NEEDED.md, validate_tests.R

# ============================================================================
# Test Harness Infrastructure
# ============================================================================

# Normalize a path for consistent cross-platform comparison (resolves symlinks,
# converts backslashes to forward slashes). Must match normalize_path_absolute()
# in coverage.R so test keys align with tracker keys.
# When the file doesn't exist yet, normalizes the parent directory (which does
# exist) to ensure symlinks like /var -> /private/var are resolved.
norm_path <- function(path) {
  if (file.exists(path)) return(normalizePath(path, winslash = "/", mustWork = TRUE))
  file.path(normalizePath(dirname(path), winslash = "/", mustWork = TRUE), basename(path))
}

# Create .arl test files with controlled content
create_arl_file <- function(content, dir = NULL) {
  if (is.null(dir)) {
    file <- tempfile(fileext = ".arl")
  } else {
    dir.create(dir, recursive = TRUE, showWarnings = FALSE)
    file <- file.path(dir, sprintf("test%d.arl", sample(1:10000, 1)))
  }
  writeLines(content, file)
  norm_path(file)
}

# Create mock arl_src for testing track()
make_arl_src <- function(file, start_line, end_line) {
  list(
    file = file,
    start_line = start_line,
    end_line = end_line
  )
}

# ============================================================================
# Phase 1: Foundation Tests (Initialize + Track)
# ============================================================================

thin <- make_cran_thinner()

test_that("initialize() creates default coverage tracker", {
  thin()
  tracker <- CoverageTracker$new()

  expect_type(tracker$coverage, "environment")
  expect_true(tracker$enabled)
  expect_equal(length(tracker$all_files), 0)
  expect_type(tracker$code_lines, "environment")
})

test_that("initialize() creates coverage environment with correct properties", {
  thin()
  tracker <- CoverageTracker$new()

  # Check environment properties
  expect_false(environmentIsLocked(tracker$coverage))
  expect_identical(parent.env(tracker$coverage), emptyenv())
})

test_that("initialize() creates code_lines environment with correct properties", {
  thin()
  tracker <- CoverageTracker$new()

  expect_false(environmentIsLocked(tracker$code_lines))
  expect_identical(parent.env(tracker$code_lines), emptyenv())
})

test_that("track() handles NULL arl_src", {
  thin()
  tracker <- CoverageTracker$new()

  # Should not error
  expect_silent(tracker$track(NULL))
  expect_equal(length(ls(tracker$coverage)), 0)
})

test_that("track() handles missing fields", {
  thin()
  tracker <- CoverageTracker$new()

  # Missing file
  expect_silent(tracker$track(list(start_line = 1, end_line = 1)))

  # Missing start_line
  expect_silent(tracker$track(list(file = "test.arl", end_line = 1)))

  # Missing end_line
  expect_silent(tracker$track(list(file = "test.arl", start_line = 1)))

  expect_equal(length(ls(tracker$coverage)), 0)
})

test_that("track() does nothing when enabled=FALSE", {
  thin()
  tmp <- create_arl_file(c("(define x 1)"))
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new()
  tracker$set_enabled(FALSE)

  arl_src <- make_arl_src(tmp, start_line = 1, end_line = 1)
  tracker$track(arl_src)

  expect_equal(length(ls(tracker$coverage)), 0)
})

test_that("track() handles non-existent file gracefully", {
  thin()
  tracker <- CoverageTracker$new()

  arl_src <- make_arl_src("/nonexistent/file.arl", start_line = 1, end_line = 1)

  # Should not error, just skip tracking
  expect_silent(tracker$track(arl_src))
  expect_equal(length(ls(tracker$coverage)), 0)
})

test_that("track() marks single line as executed", {
  thin()
  tmp <- create_arl_file(c(";; comment", "(define x 1)", "(define y 2)"))
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new()
  arl_src <- make_arl_src(tmp, start_line = 2, end_line = 2)

  tracker$track(arl_src)

  key <- paste0(tmp, ":2")
  expect_equal(tracker$coverage[[key]], 1L)
  expect_equal(length(ls(tracker$coverage)), 1)
})

test_that("track() marks multi-line range", {
  thin()
  tmp <- create_arl_file(c("(define x 1)", "(define y 2)", "(define z 3)"))
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new()
  arl_src <- make_arl_src(tmp, start_line = 1, end_line = 3)

  tracker$track(arl_src)

  expect_equal(tracker$coverage[[paste0(tmp, ":1")]], 1L)
  expect_equal(tracker$coverage[[paste0(tmp, ":2")]], 1L)
  expect_equal(tracker$coverage[[paste0(tmp, ":3")]], 1L)
  expect_equal(length(ls(tracker$coverage)), 3)
})

test_that("track() increments count on multiple executions", {
  thin()
  tmp <- create_arl_file(c("(define x 1)"))
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new()
  arl_src <- make_arl_src(tmp, start_line = 1, end_line = 1)

  tracker$track(arl_src)
  tracker$track(arl_src)
  tracker$track(arl_src)

  key <- paste0(tmp, ":1")
  expect_equal(tracker$coverage[[key]], 3L)
})

test_that("track() lazy-loads code_lines cache", {
  thin()
  tmp <- create_arl_file(c(";; comment", "(define x 1)", "", "(define y 2)"))
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new()

  # code_lines should be empty initially
  expect_equal(length(ls(tracker$code_lines)), 0)

  arl_src <- make_arl_src(tmp, start_line = 2, end_line = 2)
  tracker$track(arl_src)

  # Now code_lines should be populated for this file
  expect_true(tmp %in% ls(tracker$code_lines))

  code_line_set <- tracker$code_lines[[tmp]]
  expect_true(2 %in% code_line_set)
  expect_true(4 %in% code_line_set)
  expect_false(1 %in% code_line_set)  # comment
  expect_false(3 %in% code_line_set)  # blank
})

test_that("track() filters comment and blank lines", {
  thin()
  tmp <- create_arl_file(c(
    ";; This is a comment",
    "",
    "(define x 1)",
    "  ;; Another comment",
    "(define y 2)"
  ))
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new()

  # Track entire file
  arl_src <- make_arl_src(tmp, start_line = 1, end_line = 5)
  tracker$track(arl_src)

  # Only lines 3 and 5 should be tracked (code lines)
  expect_equal(length(ls(tracker$coverage)), 2)
  expect_true(!is.null(tracker$coverage[[paste0(tmp, ":3")]]))
  expect_true(!is.null(tracker$coverage[[paste0(tmp, ":5")]]))
  expect_null(tracker$coverage[[paste0(tmp, ":1")]])
  expect_null(tracker$coverage[[paste0(tmp, ":2")]])
  expect_null(tracker$coverage[[paste0(tmp, ":4")]])
})

test_that("track() respects custom code_line_pattern", {
  thin()
  tmp <- create_arl_file(c(
    "# Python-style comment",
    "",
    "def foo():",
    "    pass"
  ))
  on.exit(unlink(tmp), add = TRUE)

  # Use pattern that matches non-comment lines in Python-style
  tracker <- CoverageTracker$new(code_line_pattern = "^\\s*[^#\\s]")

  arl_src <- make_arl_src(tmp, start_line = 1, end_line = 4)
  tracker$track(arl_src)

  # Lines 3 and 4 should match (not starting with # or blank)
  code_lines <- tracker$code_lines[[tmp]]
  expect_true(3 %in% code_lines)
  expect_true(4 %in% code_lines)
  expect_false(1 %in% code_lines)
  expect_false(2 %in% code_lines)
})

# ============================================================================
# Phase 2: Discovery & State Management
# ============================================================================

test_that("discover_files() with NULL search_paths uses stdlib", {
  thin()
  tracker <- CoverageTracker$new(search_paths = NULL)
  tracker$discover_files()

  # Should find stdlib files
  expect_true(length(tracker$all_files) > 0)

  # All files should be .arl files
  expect_true(all(grepl("\\.arl$", tracker$all_files)))
})

test_that("discover_files() with custom search_paths", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  writeLines("(define x 1)", file.path(tmp_dir, "test1.arl"))
  writeLines("(define y 2)", file.path(tmp_dir, "test2.arl"))

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()

  expect_equal(length(tracker$all_files), 2)
  expect_true(all(grepl("\\.arl$", tracker$all_files)))
})

test_that("discover_files() searches recursively", {
  thin()
  tmp_dir <- tempfile()
  dir.create(file.path(tmp_dir, "subdir", "nested"), recursive = TRUE)
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  writeLines("(define x 1)", file.path(tmp_dir, "test1.arl"))
  writeLines("(define y 2)", file.path(tmp_dir, "subdir", "test2.arl"))
  writeLines("(define z 3)", file.path(tmp_dir, "subdir", "nested", "test3.arl"))

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()

  expect_equal(length(tracker$all_files), 3)
})

test_that("discover_files() excludes test directories by default", {
  thin()
  tmp_dir <- tempfile()
  dir.create(file.path(tmp_dir, "src"), recursive = TRUE)
  dir.create(file.path(tmp_dir, "tests"), recursive = TRUE)
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  writeLines("(define x 1)", file.path(tmp_dir, "src", "code.arl"))
  writeLines("(define test 1)", file.path(tmp_dir, "tests", "test.arl"))

  tracker <- CoverageTracker$new(search_paths = tmp_dir, include_tests = FALSE)
  tracker$discover_files()

  expect_equal(length(tracker$all_files), 1)
  expect_true(grepl("src/code.arl", tracker$all_files))
  expect_false(any(grepl("tests/", tracker$all_files)))
})

test_that("discover_files() includes test directories when include_tests=TRUE", {
  thin()
  tmp_dir <- tempfile()
  dir.create(file.path(tmp_dir, "src"), recursive = TRUE)
  dir.create(file.path(tmp_dir, "tests"), recursive = TRUE)
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  writeLines("(define x 1)", file.path(tmp_dir, "src", "code.arl"))
  writeLines("(define test 1)", file.path(tmp_dir, "tests", "test.arl"))

  tracker <- CoverageTracker$new(search_paths = tmp_dir, include_tests = TRUE)
  tracker$discover_files()

  expect_equal(length(tracker$all_files), 2)
  expect_true(any(grepl("tests/", tracker$all_files)))
})

test_that("discover_files() handles non-existent directory", {
  thin()
  tracker <- CoverageTracker$new(search_paths = "/nonexistent/directory")

  # Should not error, just return empty
  expect_silent(tracker$discover_files())
  expect_equal(length(tracker$all_files), 0)
})

test_that("discover_files() handles multiple search_paths", {
  thin()
  tmp_dir1 <- tempfile()
  tmp_dir2 <- tempfile()
  dir.create(tmp_dir1)
  dir.create(tmp_dir2)
  on.exit({
    unlink(tmp_dir1, recursive = TRUE)
    unlink(tmp_dir2, recursive = TRUE)
  })

  writeLines("(define x 1)", file.path(tmp_dir1, "test1.arl"))
  writeLines("(define y 2)", file.path(tmp_dir2, "test2.arl"))

  tracker <- CoverageTracker$new(search_paths = c(tmp_dir1, tmp_dir2))
  tracker$discover_files()

  expect_equal(length(tracker$all_files), 2)
})

test_that("discover_files() deduplicates files", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  writeLines("(define x 1)", file.path(tmp_dir, "test.arl"))

  # Pass same directory twice
  tracker <- CoverageTracker$new(search_paths = c(tmp_dir, tmp_dir))
  tracker$discover_files()

  # Should only find the file once
  expect_equal(length(tracker$all_files), 1)
})

test_that("discover_files() populates code_lines cache", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  tmp_file <- norm_path(file.path(tmp_dir, "test.arl"))
  writeLines(c(";; comment", "(define x 1)", "", "(define y 2)"), tmp_file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()

  # code_lines should be populated
  expect_true(tmp_file %in% ls(tracker$code_lines))
  code_lines <- tracker$code_lines[[tmp_file]]
  expect_true(2 %in% code_lines)
  expect_true(4 %in% code_lines)
  expect_false(1 %in% code_lines)
  expect_false(3 %in% code_lines)
})

test_that("discover_files() handles empty directory", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()

  expect_equal(length(tracker$all_files), 0)
})

test_that("get_summary() returns empty list for empty coverage", {
  thin()
  tracker <- CoverageTracker$new()

  summary <- tracker$get_summary()

  expect_type(summary, "list")
  expect_equal(length(summary), 0)
})

test_that("get_summary() returns single file/line structure", {
  thin()
  tmp <- create_arl_file(c("(define x 1)"))
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new()
  arl_src <- make_arl_src(tmp, start_line = 1, end_line = 1)
  tracker$track(arl_src)

  summary <- tracker$get_summary()

  expect_equal(length(summary), 1)
  expect_true(tmp %in% names(summary))
  expect_equal(summary[[tmp]][["1"]], 1L)
})

test_that("get_summary() returns multiple files/lines structure", {
  thin()
  tmp1 <- create_arl_file(c("(define x 1)", "(define y 2)"))
  tmp2 <- create_arl_file(c("(define z 3)"))
  on.exit({
    unlink(tmp1)
    unlink(tmp2)
  })

  tracker <- CoverageTracker$new()
  tracker$track(make_arl_src(tmp1, 1, 2))
  tracker$track(make_arl_src(tmp2, 1, 1))

  summary <- tracker$get_summary()

  expect_equal(length(summary), 2)
  expect_true(tmp1 %in% names(summary))
  expect_true(tmp2 %in% names(summary))
  expect_equal(length(summary[[tmp1]]), 2)
  expect_equal(length(summary[[tmp2]]), 1)
})

test_that("get_summary() handles malformed keys gracefully", {
  thin()
  tmp <- create_arl_file(c("(define x 1)"))
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new()

  # Manually add a malformed key (no colon)
  assign("badkey", 5L, envir = tracker$coverage)

  # Should not error
  summary <- tracker$get_summary()

  # Malformed key should be ignored
  expect_false("badkey" %in% names(summary))
})

test_that("get_summary() creates nested list structure correctly", {
  thin()
  tmp <- create_arl_file(c("(define x 1)", "(define y 2)", "(define z 3)"))
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new()
  tracker$track(make_arl_src(tmp, 1, 1))
  tracker$track(make_arl_src(tmp, 1, 1))  # Execute line 1 twice
  tracker$track(make_arl_src(tmp, 3, 3))  # Execute line 3 once

  summary <- tracker$get_summary()

  # Check nested access: summary[[file]][[line]] = count
  expect_equal(summary[[tmp]][["1"]], 2L)
  expect_null(summary[[tmp]][["2"]])  # Line 2 not executed
  expect_equal(summary[[tmp]][["3"]], 1L)
})

test_that("reset() clears empty coverage", {
  thin()
  tracker <- CoverageTracker$new()

  tracker$reset()

  expect_equal(length(ls(tracker$coverage)), 0)
})

test_that("reset() clears populated coverage", {
  thin()
  tmp <- create_arl_file(c("(define x 1)"))
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new()
  tracker$track(make_arl_src(tmp, 1, 1))

  expect_equal(length(ls(tracker$coverage)), 1)

  tracker$reset()

  expect_equal(length(ls(tracker$coverage)), 0)
})

test_that("reset() preserves all_files and code_lines", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  tmp_file <- file.path(tmp_dir, "test.arl")
  writeLines(c("(define x 1)"), tmp_file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()
  tracker$track(make_arl_src(tmp_file, 1, 1))

  expect_equal(length(tracker$all_files), 1)
  expect_equal(length(ls(tracker$code_lines)), 1)
  expect_equal(length(ls(tracker$coverage)), 1)

  tracker$reset()

  # all_files and code_lines preserved
  expect_equal(length(tracker$all_files), 1)
  expect_equal(length(ls(tracker$code_lines)), 1)
  # But coverage cleared
  expect_equal(length(ls(tracker$coverage)), 0)
})

test_that("set_enabled() toggles tracking", {
  thin()
  tmp <- create_arl_file(c("(define x 1)"))
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new()

  expect_true(tracker$enabled)

  tracker$set_enabled(FALSE)
  expect_false(tracker$enabled)

  tracker$set_enabled(TRUE)
  expect_true(tracker$enabled)
})

test_that("disabled tracker ignores track() calls", {
  thin()
  tmp <- create_arl_file(c("(define x 1)"))
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new()
  tracker$set_enabled(FALSE)

  tracker$track(make_arl_src(tmp, 1, 1))

  expect_equal(length(ls(tracker$coverage)), 0)
})

test_that("re-enabling resumes tracking", {
  thin()
  tmp <- create_arl_file(c("(define x 1)", "(define y 2)"))
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new()

  # Track line 1
  tracker$track(make_arl_src(tmp, 1, 1))
  expect_equal(length(ls(tracker$coverage)), 1)

  # Disable and try to track line 2
  tracker$set_enabled(FALSE)
  tracker$track(make_arl_src(tmp, 2, 2))
  expect_equal(length(ls(tracker$coverage)), 1)  # Still only line 1

  # Re-enable and track line 2
  tracker$set_enabled(TRUE)
  tracker$track(make_arl_src(tmp, 2, 2))
  expect_equal(length(ls(tracker$coverage)), 2)  # Now both lines
})

# ============================================================================
# Phase 3: Console Reporting
# ============================================================================

test_that("report_console() shows message for empty coverage", {
  thin()
  tracker <- CoverageTracker$new()

  output <- capture.output(tracker$report_console())

  expect_true(any(grepl("No coverage data|0\\.0+%|0/0", output)))
})

test_that("report_console() discovers files if all_files empty", {
  thin()
  # Use custom path so we know what to expect
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  tmp_file <- file.path(tmp_dir, "test.arl")
  writeLines("(define x 1)", tmp_file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)

  expect_equal(length(tracker$all_files), 0)

  # Track something so coverage isn't empty (otherwise it returns early)
  tracker$track(make_arl_src(tmp_file, 1, 1))

  # report_console should trigger discover_files
  output <- capture.output(tracker$report_console())

  expect_true(length(tracker$all_files) > 0)
})

test_that("report_console() shows single file with partial coverage", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  tmp_file <- file.path(tmp_dir, "test.arl")
  writeLines(c("(define x 1)", "(define y 2)", "(define z 3)"), tmp_file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()

  # Only track line 1
  tracker$track(make_arl_src(tmp_file, 1, 1))

  output <- capture.output(tracker$report_console())
  output_text <- paste(output, collapse = "\n")

  # Should show filename
  expect_true(grepl("test\\.arl", output_text))

  # Should show coverage percentage (33% = 1/3 lines)
  expect_true(grepl("33\\.[0-9]+%", output_text) || grepl("1/3", output_text))
})

test_that("report_console() shows multiple files sorted alphabetically", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  file_z <- file.path(tmp_dir, "z.arl")
  file_a <- file.path(tmp_dir, "a.arl")
  file_m <- file.path(tmp_dir, "m.arl")

  writeLines("(define x 1)", file_z)
  writeLines("(define y 2)", file_a)
  writeLines("(define z 3)", file_m)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()

  # Track at least one line in each file so they appear in output
  tracker$track(make_arl_src(file_z, 1, 1))
  tracker$track(make_arl_src(file_a, 1, 1))
  tracker$track(make_arl_src(file_m, 1, 1))

  output <- capture.output(tracker$report_console())
  output_text <- paste(output, collapse = "\n")

  # Find positions of filenames
  pos_a <- regexpr("a\\.arl", output_text)
  pos_m <- regexpr("m\\.arl", output_text)
  pos_z <- regexpr("z\\.arl", output_text)

  # Should be in alphabetical order
  expect_true(pos_a > 0, info = "a.arl not found in output")
  expect_true(pos_m > 0, info = "m.arl not found in output")
  expect_true(pos_z > 0, info = "z.arl not found in output")
  expect_true(pos_a < pos_m, info = "Files not in alphabetical order")
  expect_true(pos_m < pos_z, info = "Files not in alphabetical order")
})

test_that("report_console() calculates total lines correctly", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  file1 <- file.path(tmp_dir, "file1.arl")
  file2 <- file.path(tmp_dir, "file2.arl")

  writeLines(c("(define x 1)", "(define y 2)"), file1)  # 2 lines
  writeLines(c("(define z 3)", "(define w 4)", "(define v 5)"), file2)  # 3 lines

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()

  # Track 1 line from file1, 2 lines from file2 = 3/5 total
  tracker$track(make_arl_src(file1, 1, 1))
  tracker$track(make_arl_src(file2, 1, 2))

  output <- capture.output(tracker$report_console())
  output_text <- paste(output, collapse = "\n")

  # Should show total: 3/5 = 60%
  expect_true(grepl("60\\.0+%", output_text) || grepl("3/5", output_text))
})

test_that("report_console() writes to file when output_file specified", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  out_file <- tempfile(fileext = ".txt")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(out_file)
  })

  file <- file.path(tmp_dir, "test.arl")
  writeLines("(define x 1)", file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()
  tracker$track(make_arl_src(file, 1, 1))

  # Capture console to ensure nothing printed
  output <- capture.output(tracker$report_console(output_file = out_file))

  # File should exist and contain report
  expect_true(file.exists(out_file))
  file_content <- readLines(out_file)
  expect_true(length(file_content) > 0)
  expect_true(any(grepl("test\\.arl", file_content)))
})

test_that("report_console() outputs to console when no output_file", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  file <- file.path(tmp_dir, "test.arl")
  writeLines("(define x 1)", file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()
  tracker$track(make_arl_src(file, 1, 1))

  output <- capture.output(tracker$report_console())

  expect_true(length(output) > 0)
  expect_true(any(grepl("test\\.arl", output)))
})

# ============================================================================
# Phase 4: Advanced Reporting (HTML & JSON)
# ============================================================================

test_that("report_html() errors when output_file is missing", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  file <- file.path(tmp_dir, "test.arl")
  writeLines("(define x 1)", file)

  tracker <- CoverageTracker$new(
    search_paths = tmp_dir,
    output_prefix = "test_prefix"
  )
  tracker$discover_files()

  expect_error(tracker$report_html(), "output_file is required")
})

test_that("report_html() uses custom output path", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  html_file <- tempfile(fileext = ".html")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(html_file)
  })

  file <- file.path(tmp_dir, "test.arl")
  writeLines("(define x 1)", file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()
  suppressMessages(tracker$report_html(output_file = html_file))

  expect_true(file.exists(html_file))
})

test_that("report_html() auto-creates output directory", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  output_dir <- file.path(tmp_dir, "deep", "nested", "dir")
  html_file <- file.path(output_dir, "report.html")
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  file <- file.path(tmp_dir, "test.arl")
  writeLines("(define x 1)", file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()

  expect_false(dir.exists(output_dir))

  suppressMessages(tracker$report_html(output_file = html_file))

  expect_true(dir.exists(output_dir))
  expect_true(file.exists(html_file))
})

test_that("report_html() auto-discovers files if needed", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  html_file <- tempfile(fileext = ".html")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(html_file)
  })

  file <- file.path(tmp_dir, "test.arl")
  writeLines("(define x 1)", file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)

  # Don't call discover_files()
  expect_equal(length(tracker$all_files), 0)

  suppressMessages(tracker$report_html(output_file = html_file))

  # Should have auto-discovered
  expect_true(length(tracker$all_files) > 0)
  expect_true(file.exists(html_file))
})

test_that("report_html() generates valid HTML structure", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  html_file <- tempfile(fileext = ".html")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(html_file)
  })

  file <- file.path(tmp_dir, "test.arl")
  writeLines("(define x 1)", file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()
  suppressMessages(tracker$report_html(output_file = html_file))

  html_content <- paste(readLines(html_file), collapse = "\n")

  expect_true(grepl("<!DOCTYPE html>", html_content))
  expect_true(grepl("<title>", html_content))
  expect_true(grepl("<table>", html_content))
})

test_that("report_html() uses custom report_title", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  html_file <- tempfile(fileext = ".html")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(html_file)
  })

  file <- file.path(tmp_dir, "test.arl")
  writeLines("(define x 1)", file)

  tracker <- CoverageTracker$new(
    search_paths = tmp_dir,
    report_title = "Custom Coverage Report"
  )
  tracker$discover_files()
  suppressMessages(tracker$report_html(output_file = html_file))

  html_content <- paste(readLines(html_file), collapse = "\n")

  expect_true(grepl("<title>Custom Coverage Report</title>", html_content))
  expect_true(grepl("<h1>Custom Coverage Report</h1>", html_content))
})

test_that("report_html() includes summary table", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  html_file <- tempfile(fileext = ".html")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(html_file)
  })

  file1 <- file.path(tmp_dir, "test1.arl")
  file2 <- file.path(tmp_dir, "test2.arl")
  writeLines(c("(define x 1)", "(define y 2)"), file1)
  writeLines("(define z 3)", file2)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()
  tracker$track(make_arl_src(file1, 1, 1))
  suppressMessages(tracker$report_html(output_file = html_file))

  html_content <- paste(readLines(html_file), collapse = "\n")

  expect_true(grepl("<table>", html_content))
  expect_true(grepl("<th>File</th>", html_content))
  expect_true(grepl("<th>Coverage %</th>", html_content))
})

test_that("report_html() uses coverage percentage CSS classes", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  html_file <- tempfile(fileext = ".html")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(html_file)
  })

  # Create file with multiple lines for varying coverage
  file <- file.path(tmp_dir, "test.arl")
  writeLines(c("(define a 1)", "(define b 2)", "(define c 3)", "(define d 4)"), file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()

  # Track 3 out of 4 lines = 75%
  tracker$track(make_arl_src(file, 1, 3))

  suppressMessages(tracker$report_html(output_file = html_file))

  html_content <- paste(readLines(html_file), collapse = "\n")

  # Should have CSS classes for coverage percentage
  expect_true(grepl("class=.(pct-high|pct-medium|pct-low).", html_content))
})

test_that("report_html() includes detailed file view sections", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  html_file <- tempfile(fileext = ".html")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(html_file)
  })

  file <- file.path(tmp_dir, "test.arl")
  writeLines(c("(define x 1)", "(define y 2)"), file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()
  tracker$track(make_arl_src(file, 1, 1))
  suppressMessages(tracker$report_html(output_file = html_file))

  html_content <- paste(readLines(html_file), collapse = "\n")

  # Should have h2 for file sections (not h3)
  expect_true(grepl("<h2>", html_content))

  # Should have file-content div
  expect_true(grepl("class=.file-content.", html_content))
})

test_that("report_html() shows line-by-line coverage with classes", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  html_file <- tempfile(fileext = ".html")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(html_file)
  })

  file <- file.path(tmp_dir, "test.arl")
  writeLines(c("(define x 1)", "(define y 2)", "(define z 3)"), file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()

  # Track only line 1
  tracker$track(make_arl_src(file, 1, 1))

  suppressMessages(tracker$report_html(output_file = html_file))

  html_content <- paste(readLines(html_file), collapse = "\n")

  # Should have covered and uncovered classes
  expect_true(grepl("covered", html_content))
  expect_true(grepl("uncovered", html_content))
})

test_that("report_html() displays hit counts", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  html_file <- tempfile(fileext = ".html")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(html_file)
  })

  file <- file.path(tmp_dir, "test.arl")
  writeLines("(define x 1)", file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()

  # Track line 3 times
  tracker$track(make_arl_src(file, 1, 1))
  tracker$track(make_arl_src(file, 1, 1))
  tracker$track(make_arl_src(file, 1, 1))

  suppressMessages(tracker$report_html(output_file = html_file))

  html_content <- paste(readLines(html_file), collapse = "\n")

  # Should show hit count in format like "3x"
  expect_true(grepl("3x", html_content))
})

test_that("report_html() properly escapes HTML special characters", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  html_file <- tempfile(fileext = ".html")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(html_file)
  })

  file <- file.path(tmp_dir, "test.arl")
  content <- c(
    "(define x \"<tag>\")",
    "(define y \"a & b\")",
    "(define z \"x > y\")"
  )
  writeLines(content, file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()
  suppressMessages(tracker$report_html(output_file = html_file))

  html_content <- paste(readLines(html_file), collapse = "\n")

  # Critical: HTML special characters must be escaped
  expect_true(grepl("&lt;tag&gt;", html_content))
  expect_true(grepl("a &amp; b", html_content))
  expect_true(grepl("x &gt; y", html_content))
})

test_that("report_html() generates valid HTML for empty coverage", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  html_file <- tempfile(fileext = ".html")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(html_file)
  })

  file <- file.path(tmp_dir, "test.arl")
  writeLines("(define x 1)", file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()

  # Don't track anything
  suppressMessages(tracker$report_html(output_file = html_file))

  expect_true(file.exists(html_file))

  html_content <- paste(readLines(html_file), collapse = "\n")
  expect_true(grepl("<!DOCTYPE html>", html_content))
  expect_true(grepl("<title>", html_content))
})

test_that("report_json() errors when output_file is missing", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  file <- file.path(tmp_dir, "test.arl")
  writeLines("(define x 1)", file)

  tracker <- CoverageTracker$new(
    search_paths = tmp_dir,
    output_prefix = "test_json"
  )
  tracker$discover_files()

  expect_error(tracker$report_json(), "output_file is required")
})

test_that("report_json() uses custom output path", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  json_file <- tempfile(fileext = ".json")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(json_file)
  })

  file <- file.path(tmp_dir, "test.arl")
  writeLines("(define x 1)", file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()
  suppressMessages(tracker$report_json(output_file = json_file))

  expect_true(file.exists(json_file))
})

test_that("report_json() auto-creates output directory", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  output_dir <- file.path(tmp_dir, "deep", "nested", "dir")
  json_file <- file.path(output_dir, "coverage.json")
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  file <- file.path(tmp_dir, "test.arl")
  writeLines("(define x 1)", file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()

  expect_false(dir.exists(output_dir))

  suppressMessages(tracker$report_json(output_file = json_file))

  expect_true(dir.exists(output_dir))
  expect_true(file.exists(json_file))
})

test_that("report_json() auto-discovers files if needed", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  json_file <- tempfile(fileext = ".json")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(json_file)
  })

  file <- file.path(tmp_dir, "test.arl")
  writeLines("(define x 1)", file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)

  expect_equal(length(tracker$all_files), 0)

  suppressMessages(tracker$report_json(output_file = json_file))

  expect_true(length(tracker$all_files) > 0)
  expect_true(file.exists(json_file))
})

test_that("report_json() generates codecov structure", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  json_file <- tempfile(fileext = ".json")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(json_file)
  })

  file <- file.path(tmp_dir, "test.arl")
  writeLines("(define x 1)", file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()
  suppressMessages(tracker$report_json(output_file = json_file))

  if (requireNamespace("jsonlite", quietly = TRUE)) {
    data <- jsonlite::fromJSON(json_file)
    expect_true("coverage" %in% names(data))
    expect_equal(length(data$coverage), 1)
  }
})

test_that("report_json() uses line coverage list format", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  json_file <- tempfile(fileext = ".json")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(json_file)
  })

  file <- file.path(tmp_dir, "test.arl")
  writeLines(c("(define x 1)", "(define y 2)"), file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()

  # Track line 1 twice
  tracker$track(make_arl_src(file, 1, 1))
  tracker$track(make_arl_src(file, 1, 1))

  suppressMessages(tracker$report_json(output_file = json_file))

  if (requireNamespace("jsonlite", quietly = TRUE)) {
    data <- jsonlite::fromJSON(json_file)

    # Get the coverage data for this file
    coverage_list <- data$coverage[[1]]

    # Line 1 should have count 2
    expect_equal(coverage_list[[1]], 2)

    # Line 2 should have count 0 (uncovered)
    expect_equal(coverage_list[[2]], 0)
  }
})

test_that("report_json() uses null for non-code lines", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  json_file <- tempfile(fileext = ".json")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(json_file)
  })

  file <- file.path(tmp_dir, "test.arl")
  writeLines(c(";; comment", "(define x 1)", "", "(define y 2)"), file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()
  tracker$track(make_arl_src(file, 2, 2))

  suppressMessages(tracker$report_json(output_file = json_file))

  if (requireNamespace("jsonlite", quietly = TRUE)) {
    data <- jsonlite::fromJSON(json_file)
    coverage_list <- data$coverage[[1]]

    # Line 1 (comment) should be null/NA (jsonlite converts null to NA)
    expect_true(is.na(coverage_list[[1]]))

    # Line 2 (code) should have count
    expect_equal(coverage_list[[2]], 1)

    # Line 3 (blank) should be null/NA
    expect_true(is.na(coverage_list[[3]]))

    # Line 4 (code) should have count 0 (uncovered)
    expect_equal(coverage_list[[4]], 0)
  }
})

test_that("report_json() shows 0 for uncovered code lines", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  json_file <- tempfile(fileext = ".json")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(json_file)
  })

  file <- file.path(tmp_dir, "test.arl")
  writeLines(c("(define x 1)", "(define y 2)"), file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()

  # Track only line 1
  tracker$track(make_arl_src(file, 1, 1))

  suppressMessages(tracker$report_json(output_file = json_file))

  if (requireNamespace("jsonlite", quietly = TRUE)) {
    data <- jsonlite::fromJSON(json_file)
    coverage_list <- data$coverage[[1]]

    # Line 1 covered
    expect_equal(coverage_list[[1]], 1)

    # Line 2 uncovered (should be 0, not null)
    expect_equal(coverage_list[[2]], 0)
  }
})

# ============================================================================
# Phase 5: Integration Tests
# ============================================================================

test_that("Engine accepts coverage_tracker parameter", {
  thin()
  tracker <- CoverageTracker$new()
  engine <- Engine$new(coverage_tracker = tracker)

  expect_s3_class(engine, "ArlEngine")
})

test_that("Engine tracks coverage for executed code", {
  thin()
  tmp <- norm_path(tempfile(fileext = ".arl"))
  writeLines(c(
    "(define add (lambda (x y) (+ x y)))",
    "(define result (add 1 2))"
  ), tmp)
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new(search_paths = dirname(tmp))
  engine <- Engine$new(coverage_tracker = tracker)

  tracker$discover_files()
  engine$load_file_in_env(tmp)

  summary <- tracker$get_summary()

  # Both lines should be covered
  expect_equal(length(summary[[tmp]]), 2)
  expect_true("1" %in% names(summary[[tmp]]))
  expect_true("2" %in% names(summary[[tmp]]))
})

test_that("disabled coverage tracker doesn't track", {
  thin()
  tmp <- norm_path(tempfile(fileext = ".arl"))
  writeLines(c("(define x 1)"), tmp)
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new(search_paths = dirname(tmp))
  tracker$set_enabled(FALSE)

  engine <- Engine$new(coverage_tracker = tracker)

  tracker$discover_files()
  engine$load_file_in_env(tmp)

  summary <- tracker$get_summary()

  # Should have no coverage data
  expect_equal(length(summary), 0)
})

test_that("coverage tracking persists across multiple evaluations", {
  thin()
  tmp <- norm_path(tempfile(fileext = ".arl"))
  writeLines(c(
    "(define counter 0)",
    "(define inc (lambda () (set! counter (+ counter 1))))"
  ), tmp)
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new(search_paths = dirname(tmp))
  engine <- Engine$new(coverage_tracker = tracker)

  tracker$discover_files()

  # Load file multiple times
  engine$load_file_in_env(tmp)
  engine$load_file_in_env(tmp)
  engine$load_file_in_env(tmp)

  summary <- tracker$get_summary()

  # Line 1 should be executed 3 times
  expect_equal(summary[[tmp]][["1"]], 3)

  # Line 2 should be executed 3 times
  expect_equal(summary[[tmp]][["2"]], 3)
})

test_that("Engine$get_coverage() returns NULL when coverage not enabled", {
  thin()
  # Explicitly create engine without coverage tracker to avoid inheriting
  # one from getOption("arl.coverage_tracker") under covr instrumentation
  engine <- Engine$new()
  expect_null(engine$get_coverage())
})

test_that("Engine$get_coverage() returns data frame with correct structure", {
  thin()
  tmp <- norm_path(tempfile(fileext = ".arl"))
  writeLines(c("(define x 1)", "(define y 2)"), tmp)
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new(search_paths = dirname(tmp))
  engine <- Engine$new(coverage_tracker = tracker)
  tracker$discover_files()

  engine$load_file_in_env(tmp)

  result <- suppressWarnings(
    engine$get_coverage(),
    classes = "simpleWarning"
  )

  expect_s3_class(result, "data.frame")
  expect_true(all(c("file", "total_lines", "covered_lines", "coverage_pct") %in% names(result)))
  expect_true(nrow(result) > 0)

  total <- attr(result, "total")
  expect_type(total, "list")
  expect_true(all(c("total_lines", "covered_lines", "coverage_pct") %in% names(total)))
})

test_that("Engine$get_coverage() reports correct coverage stats", {
  thin()
  tmp <- norm_path(tempfile(fileext = ".arl"))
  writeLines(c("(define x 1)", "(define y 2)", "(define z 3)"), tmp)
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new(search_paths = dirname(tmp))
  engine <- Engine$new(coverage_tracker = tracker)
  tracker$discover_files()

  engine$load_file_in_env(tmp)

  result <- suppressWarnings(
    engine$get_coverage(),
    classes = "simpleWarning"
  )
  row <- result[result$file == tmp, ]

  expect_equal(nrow(row), 1)
  expect_equal(row$total_lines, 3)
  expect_equal(row$covered_lines, 3)
  expect_equal(row$coverage_pct, 100)
})

# ============================================================================
# Edge Cases
# ============================================================================

test_that("handles empty files", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  file <- norm_path(file.path(tmp_dir, "empty.arl"))
  writeLines(character(0), file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()

  # Should not error
  expect_equal(length(tracker$all_files), 1)

  # code_lines should show 0 code lines
  expect_true(file %in% ls(tracker$code_lines))
  expect_equal(length(tracker$code_lines[[file]]), 0)
})

test_that("handles files with only comments and blanks", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  file <- file.path(tmp_dir, "comments.arl")
  writeLines(c(";; Comment 1", "", ";; Comment 2", ""), file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()

  # Should have 0 code lines
  expect_equal(length(tracker$code_lines[[file]]), 0)

  # Report should handle gracefully
  output <- capture.output(tracker$report_console())
  expect_true(length(output) > 0)
})

test_that("handles 100% coverage", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)

  file <- file.path(tmp_dir, "full.arl")
  writeLines(c("(define x 1)", "(define y 2)"), file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()

  # Track all lines
  tracker$track(make_arl_src(file, 1, 2))

  output <- capture.output(tracker$report_console())
  output_text <- paste(output, collapse = "\n")

  # Should show 100%
  expect_true(grepl("100\\.0+%", output_text))
})

test_that("handles very large execution counts", {
  thin()
  tmp <- create_arl_file(c("(define x 1)"))
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new()
  arl_src <- make_arl_src(tmp, 1, 1)

  # Execute 1000 times (reduced from 10000 for speed)
  for (i in 1:1000) {
    tracker$track(arl_src)
  }

  key <- paste0(tmp, ":1")
  expect_equal(tracker$coverage[[key]], 1000L)

  # Reports should handle large counts
  output <- capture.output(suppressWarnings(
    tracker$report_console(),
    classes = "simpleWarning"
  ))
  expect_true(length(output) > 0)
})

test_that("HTML escaping prevents XSS", {
  thin()
  tmp_dir <- tempfile()
  dir.create(tmp_dir)
  html_file <- tempfile(fileext = ".html")
  on.exit({
    unlink(tmp_dir, recursive = TRUE)
    unlink(html_file)
  })

  file <- file.path(tmp_dir, "xss.arl")
  # Potential XSS vectors
  content <- c(
    "(define x \"<script>alert('xss')</script>\")",
    "(define y \"<img src=x onerror=alert(1)>\")",
    "(define z \"<iframe src=evil.com></iframe>\")"
  )
  writeLines(content, file)

  tracker <- CoverageTracker$new(search_paths = tmp_dir)
  tracker$discover_files()
  suppressMessages(tracker$report_html(output_file = html_file))

  html_content <- paste(readLines(html_file), collapse = "\n")

  # Check that dangerous strings are properly escaped
  expect_true(grepl("&lt;script&gt;", html_content))
  expect_true(grepl("&lt;img", html_content))
  expect_true(grepl("&lt;iframe", html_content))

  # Should not have literal unescaped versions
  expect_false(grepl("<script>alert", html_content))
  expect_false(grepl("<img src=x onerror", html_content))
})

# ============================================================================
# Phase 6: Call-time Coverage Tests (function body instrumentation)
# ============================================================================

test_that("uncalled function body is NOT covered", {
  thin()
  tmp <- norm_path(tempfile(fileext = ".arl"))
  writeLines(c(
    "(define f",
    "  (lambda (x)",
    "    (+ x 1)",
    "    (* x 2)))"
  ), tmp)
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new(search_paths = dirname(tmp))
  engine <- Engine$new(coverage_tracker = tracker)
  tracker$discover_files()

  # Load file but do NOT call f
  engine$load_file_in_env(tmp)

  summary <- tracker$get_summary()

  # Line 1 should be covered (top-level define is evaluated)
  expect_true("1" %in% names(summary[[tmp]]))

  # Lines 3 and 4 (lambda body) should NOT be covered since f was never called
  expect_null(summary[[tmp]][["3"]])
  expect_null(summary[[tmp]][["4"]])
})

test_that("called function body IS covered", {
  thin()
  tmp <- norm_path(tempfile(fileext = ".arl"))
  writeLines(c(
    "(define f",
    "  (lambda (x)",
    "    (+ x 1)",
    "    (* x 2)))",
    "(f 5)"
  ), tmp)
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new(search_paths = dirname(tmp))
  engine <- Engine$new(coverage_tracker = tracker)
  tracker$discover_files()

  engine$load_file_in_env(tmp)

  summary <- tracker$get_summary()

  # Line 1 should be covered (top-level define)
  expect_true("1" %in% names(summary[[tmp]]))

  # Line 5 should be covered (top-level call)
  expect_true("5" %in% names(summary[[tmp]]))

  # Lines 3 and 4 (lambda body) should now be covered since f was called
  expect_true(!is.null(summary[[tmp]][["3"]]))
  expect_true(!is.null(summary[[tmp]][["4"]]))
})

test_that("module loading does not mark entire file as covered", {
  thin()
  tmp <- norm_path(tempfile(fileext = ".arl"))
  writeLines(c(
    "(module test-cov-mod (export f g)",
    "  (define f",
    "    (lambda (x)",
    "      (+ x 1)))",
    "  (define g",
    "    (lambda (x)",
    "      (* x 2)))",
    "  (define h",
    "    (lambda (x)",
    "      (- x 1))))"
  ), tmp)
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new(search_paths = dirname(tmp))
  engine <- Engine$new(coverage_tracker = tracker)
  tracker$discover_files()

  # Load the module - defines f, g, h but doesn't call them
  engine$load_file_in_env(tmp)

  summary <- tracker$get_summary()

  # Module line and define lines should be partially covered
  # but function body lines (4, 7, 10) should NOT be covered
  # since none of f, g, h were actually called
  expect_null(summary[[tmp]][["4"]])
  expect_null(summary[[tmp]][["7"]])
  expect_null(summary[[tmp]][["10"]])
})

# ============================================================================
# Phase 7: Branch-level Coverage Tests
# ============================================================================

test_that("if expression only covers taken then-branch", {
  thin()
  tmp <- norm_path(tempfile(fileext = ".arl"))
  writeLines(c(
    "(define f",
    "  (lambda (x)",
    "    (if (> x 0)",
    "      (+ x 1)",
    "      (* x 2))))",
    "(f 5)"
  ), tmp)
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new(search_paths = dirname(tmp))
  engine <- Engine$new(coverage_tracker = tracker)
  tracker$discover_files()

  engine$load_file_in_env(tmp)

  summary <- tracker$get_summary()

  # Line 4 (then-branch) should be covered
  expect_true(!is.null(summary[[tmp]][["4"]]))

  # Line 5 (else-branch) should NOT be covered
  expect_null(summary[[tmp]][["5"]])
})

test_that("if expression only covers taken else-branch", {
  thin()
  tmp <- norm_path(tempfile(fileext = ".arl"))
  writeLines(c(
    "(define f",
    "  (lambda (x)",
    "    (if (> x 0)",
    "      (+ x 1)",
    "      (* x 2))))",
    "(f -3)"
  ), tmp)
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new(search_paths = dirname(tmp))
  engine <- Engine$new(coverage_tracker = tracker)
  tracker$discover_files()

  engine$load_file_in_env(tmp)

  summary <- tracker$get_summary()

  # Line 4 (then-branch) should NOT be covered
  expect_null(summary[[tmp]][["4"]])

  # Line 5 (else-branch) should be covered
  expect_true(!is.null(summary[[tmp]][["5"]]))
})

test_that("if expression covers both branches when both are taken", {
  thin()
  tmp <- norm_path(tempfile(fileext = ".arl"))
  writeLines(c(
    "(define f",
    "  (lambda (x)",
    "    (if (> x 0)",
    "      (+ x 1)",
    "      (* x 2))))",
    "(f 5)",
    "(f -3)"
  ), tmp)
  on.exit(unlink(tmp), add = TRUE)

  tracker <- CoverageTracker$new(search_paths = dirname(tmp))
  engine <- Engine$new(coverage_tracker = tracker)
  tracker$discover_files()

  engine$load_file_in_env(tmp)

  summary <- tracker$get_summary()

  # Both branches should be covered since we called f with positive and negative
  expect_true(!is.null(summary[[tmp]][["4"]]))
  expect_true(!is.null(summary[[tmp]][["5"]]))
})

Try the arl package in your browser

Any scripts or data that you put into this service are public.

arl documentation built on March 19, 2026, 5:09 p.m.