#' @srrstats {G5.2a} *Each error message produced by stop() is unique.* # Helper: extract text value from tool results (either character or ContentToolResult) tool_text <- function(result) { if (is.character(result)) { return(result) } # ContentToolResult S7 object tryCatch(result@value, error = function(e) as.character(result)) } # ============================================================================ # JSON Parsing & Input Coercion # ============================================================================ test_that("parse_json handles valid JSON", { skip_if_not_installed("jsonlite") expect_equal(PRA:::parse_json("[1, 2, 3]"), c(1, 2, 3)) expect_equal(PRA:::parse_json("null"), NULL) expect_equal(PRA:::parse_json(""), NULL) expect_equal(PRA:::parse_json(NULL), NULL) }) test_that("parse_json handles common LLM formatting mistakes", { skip_if_not_installed("jsonlite") # Bare comma-separated numbers (no brackets) expect_equal(PRA:::parse_json("0.3, 0.2"), c(0.3, 0.2)) # Trailing comma expect_equal(PRA:::parse_json("[0.3, 0.2,]"), c(0.3, 0.2)) # Single quotes instead of double expect_equal(PRA:::parse_json("['hello', 'world']"), c("hello", "world")) # Whitespace padding expect_equal(PRA:::parse_json(" [1, 2, 3] "), c(1, 2, 3)) # Smart quotes around value (LLM wraps in curly quotes) expect_equal(PRA:::parse_json("\u201c0.3\u201d"), "0.3") }) test_that("as_numeric_input coerces to numeric", { skip_if_not_installed("jsonlite") expect_equal(PRA:::as_numeric_input("[0.3, 0.2]"), c(0.3, 0.2)) expect_equal(PRA:::as_numeric_input("0.3, 0.2"), c(0.3, 0.2)) expect_null(PRA:::as_numeric_input(NULL)) # Already numeric expect_equal(PRA:::as_numeric_input(c(0.3, 0.2)), c(0.3, 0.2)) }) test_that("as_r_input passes R objects through unchanged", { expect_equal(PRA:::as_r_input(c(1, 2, 3)), c(1, 2, 3)) expect_equal(PRA:::as_r_input(list(a = 1)), list(a = 1)) expect_null(PRA:::as_r_input(NULL)) }) test_that("as_r_input parses JSON strings", { skip_if_not_installed("jsonlite") expect_equal(PRA:::as_r_input("[1, 2, 3]"), c(1, 2, 3)) expect_null(PRA:::as_r_input("null")) expect_null(PRA:::as_r_input("")) }) test_that("parse_task_dists converts JSON to list-of-lists", { skip_if_not_installed("jsonlite") json <- '[{"type":"normal","mean":10,"sd":2},{"type":"uniform","min":8,"max":12}]' result <- PRA:::parse_task_dists(json) expect_type(result, "list") expect_length(result, 2) expect_equal(result[[1]]$type, "normal") expect_equal(result[[1]]$mean, 10) expect_equal(result[[2]]$type, "uniform") }) test_that("parse_json_param provides contextual errors", { skip_if_not_installed("jsonlite") expect_error(PRA:::parse_json_param("[invalid", "my_param", "my_tool"), "my_param.*my_tool") expect_null(PRA:::parse_json_param("null", "x", "y")) expect_null(PRA:::parse_json_param("", "x", "y")) expect_null(PRA:::parse_json_param(NULL, "x", "y")) }) # ============================================================================ # Formatting Helpers # ============================================================================ test_that("summarize_distribution returns correct structure", { x <- rnorm(1000, mean = 30, sd = 5) result <- PRA:::summarize_distribution(x) expect_type(result, "list") expect_true("mean" %in% names(result)) expect_true("sd" %in% names(result)) expect_true("percentiles" %in% names(result)) expect_equal(names(result$percentiles), c("P5", "P10", "P25", "P50", "P75", "P90", "P95")) expect_true(result$percentiles$P5 < result$percentiles$P50) expect_true(result$percentiles$P50 < result$percentiles$P95) }) test_that("format_result_table produces readable text", { result <- PRA:::format_result_table(Mean = 30.5, SD = 2.1) expect_type(result, "character") expect_true(grepl("Mean", result)) expect_true(grepl("30.5", result)) expect_true(grepl("SD", result)) }) test_that("format_distribution produces readable text", { x <- rnorm(1000, mean = 30, sd = 5) result <- PRA:::format_distribution(x) expect_type(result, "character") expect_true(grepl("Summary Statistics", result)) expect_true(grepl("Percentiles", result)) expect_true(grepl("P50", result)) expect_true(grepl("P95", result)) }) test_that("html_result_table produces HTML table", { result <- PRA:::html_result_table(Mean = 30.5, SD = 2.1) expect_type(result, "character") expect_true(grepl(" 0) }) test_that("validate_task_dists catches errors", { skip_if_not_installed("jsonlite") expect_error(PRA:::validate_task_dists(list(), "test"), "non-empty") expect_error(PRA:::validate_task_dists(list(list(type = "unknown")), "test"), "invalid type") expect_error(PRA:::validate_task_dists(list(list(type = "normal", mean = 10)), "test"), "missing required") expect_invisible(PRA:::validate_task_dists(list(list(type = "normal", mean = 10, sd = 2)), "test")) }) test_that("get_ollama_models returns character vector", { result <- PRA:::get_ollama_models() expect_type(result, "character") expect_true(length(result) > 0) }) # ============================================================================ # Input Validation Tests # ============================================================================ test_that("mcs_tool validates num_sims", { skip_if_not_installed("jsonlite") task_json <- '[{"type":"normal","mean":10,"sd":2}]' expect_error(PRA:::mcs_tool(-1, task_json), "positive integer") expect_error(PRA:::mcs_tool(0, task_json), "positive integer") }) test_that("mcs_tool validates distribution types", { skip_if_not_installed("jsonlite") expect_error(PRA:::mcs_tool(100, '[{"type":"beta","a":1,"b":2}]'), "invalid type") expect_error(PRA:::mcs_tool(100, '[{"type":"normal"}]'), "missing required parameters") expect_error(PRA:::mcs_tool(100, '[{"type":"triangular","a":5}]'), "missing required parameters") }) test_that("evm_analysis_tool validates inputs", { skip_if_not_installed("jsonlite") expect_error(PRA:::evm_analysis_tool( bac = -100, schedule_json = "[0.5, 1.0]", time_period = 1, actual_per_complete = 0.5, actual_costs_json = "[1000]" ), "positive number") expect_error(PRA:::evm_analysis_tool( bac = 100000, schedule_json = "[0.5, 1.0]", time_period = 0, actual_per_complete = 0.5, actual_costs_json = "[1000]" ), "positive integer") expect_error(PRA:::evm_analysis_tool( bac = 100000, schedule_json = "[0.5, 1.0]", time_period = 5, actual_per_complete = 0.5, actual_costs_json = "[1000]" ), "exceeds schedule length") expect_error(PRA:::evm_analysis_tool( bac = 100000, schedule_json = "[0.5, 1.0]", time_period = 1, actual_per_complete = 1.5, actual_costs_json = "[1000]" ), "between 0 and 1") }) test_that("contingency_tool errors without prior MCS", { skip_if_not_installed("jsonlite") env <- PRA:::.pra_agent_env old <- env$last_mcs env$last_mcs <- NULL result <- PRA:::contingency_tool(0.95, 0.50) expect_true(grepl("Run mcs_tool first", result)) env$last_mcs <- old }) # ============================================================================ # Numerical Correctness Tests — Tool wrappers produce correct values # ============================================================================ test_that("smm_tool computes correct total mean, variance, and std dev", { skip_if_not_installed("jsonlite") result <- PRA:::smm_tool("[10, 15, 20, 8]", "[4, 9, 16, 2]") text <- tool_text(result) # total_mean = 10+15+20+8 = 53 expect_true(grepl("53", text)) # total_var = 4+9+16+2 = 31 expect_true(grepl("31", text)) # total_std = sqrt(31) = 5.5678 expect_true(grepl("5\\.567[0-9]", text)) }) test_that("evm_analysis_tool computes correct metric values", { skip_if_not_installed("jsonlite") result <- PRA:::evm_analysis_tool( bac = 100000, schedule_json = "[0.1, 0.2, 0.4, 0.7, 1.0]", time_period = 3, actual_per_complete = 0.35, actual_costs_json = "[10000, 22000, 37000]", cumulative = "true" ) text <- tool_text(result) # PV = 100000 * 0.4 = 40,000 expect_true(grepl("40,000", text)) # EV = 100000 * 0.35 = 35,000 expect_true(grepl("35,000", text)) # AC = 37,000 (cumulative at period 3) expect_true(grepl("37,000", text)) # SV = EV - PV = 35000 - 40000 = -5,000 expect_true(grepl("-5,000", text)) # CV = EV - AC = 35000 - 37000 = -2,000 expect_true(grepl("-2,000", text)) # SPI = 35000/40000 = 0.875 expect_true(grepl("0\\.875", text)) # CPI = 35000/37000 = 0.9459 expect_true(grepl("0\\.945[0-9]", text)) # EAC(typical) = BAC/CPI = 100000/0.9459 = 105,714.29 expect_true(grepl("105,714", text)) # EAC(atypical) = AC + (BAC - EV) = 37000 + 65000 = 102,000 expect_true(grepl("102,000", text)) }) test_that("evm_analysis_tool auto-converts integer percentages", { skip_if_not_installed("jsonlite") # 35 (integer-like) should be auto-converted to 0.35 result <- PRA:::evm_analysis_tool( bac = 100000, schedule_json = "[0.5, 1.0]", time_period = 1, actual_per_complete = 35, actual_costs_json = "[30000]", cumulative = "true" ) text <- tool_text(result) # EV = 100000 * 0.35 = 35,000 expect_true(grepl("35,000", text)) }) test_that("evm_analysis_tool does not auto-convert decimal values", { skip_if_not_installed("jsonlite") # 0.55 should NOT be converted (it's already a decimal) result <- PRA:::evm_analysis_tool( bac = 100000, schedule_json = "[0.5, 1.0]", time_period = 1, actual_per_complete = 0.55, actual_costs_json = "[50000]", cumulative = "true" ) text <- tool_text(result) # EV = 100000 * 0.55 = 55,000 expect_true(grepl("55,000", text)) }) test_that("individual EVM tools compute correct values", { skip_if_not_installed("jsonlite") # PV = BAC * schedule[period] = 100000 * 0.4 = 40,000 result_pv <- PRA:::pv_tool(100000, "[0.1, 0.2, 0.4, 0.7, 1.0]", 3) expect_true(grepl("40,000", result_pv)) # EV = BAC * actual_per_complete = 100000 * 0.35 = 35,000 result_ev <- PRA:::ev_tool(100000, 0.35) expect_true(grepl("35,000", result_ev)) # AC = cumulative costs at period 3 = 37,000 result_ac <- PRA:::ac_tool("[10000, 22000, 37000]", 3, "true") expect_true(grepl("37,000", result_ac)) # SV = EV - PV = 35000 - 40000 = -5,000 result_sv <- PRA:::sv_tool(35000, 40000) expect_true(grepl("-5,000", result_sv)) # CV = EV - AC = 35000 - 37000 = -2,000 result_cv <- PRA:::cv_tool(35000, 37000) expect_true(grepl("-2,000", result_cv)) # SPI = EV/PV = 35000/40000 = 0.875 result_spi <- PRA:::spi_tool(35000, 40000) expect_true(grepl("0\\.875", result_spi)) # CPI = EV/AC = 35000/37000 = 0.9459... result_cpi <- PRA:::cpi_tool(35000, 37000) expect_true(grepl("0\\.945[0-9]", result_cpi)) # EAC(atypical) = AC + (BAC - EV) = 37000 + 65000 = 102,000 result_eac <- PRA:::eac_tool(100000, "atypical", ac = 37000, ev = 35000) expect_true(grepl("102,000", result_eac)) # ETC = (BAC - EV)/CPI = 65000/0.9459 = 68,714.29 result_etc <- PRA:::etc_tool(100000, 35000, 0.9459) expect_true(grepl("68,71[0-9]", result_etc)) # VAC = BAC - EAC = 100000 - 110000 = -10,000 result_vac <- PRA:::vac_tool(100000, 110000) expect_true(grepl("-10,000", result_vac)) # TCPI = (BAC - EV)/(BAC - AC) = 65000/63000 = 1.0317... result_tcpi <- PRA:::tcpi_tool(100000, 35000, 37000, "bac") expect_true(grepl("1\\.031[0-9]", result_tcpi)) }) test_that("risk_prob_tool computes correct prior probability", { skip_if_not_installed("jsonlite") result <- PRA:::risk_prob_tool("[0.3, 0.2]", "[0.8, 0.6]", "[0.2, 0.4]") text <- tool_text(result) # P(R) = (0.8*0.3 + 0.2*0.7) + (0.6*0.2 + 0.4*0.8) = 0.38 + 0.44 = 0.82 expect_true(grepl("0\\.82", text)) expect_true(grepl("82", text)) # 82% }) test_that("risk_post_prob_tool computes correct posterior probability", { skip_if_not_installed("jsonlite") result <- PRA:::risk_post_prob_tool("[0.3, 0.2]", "[0.8, 0.6]", "[0.2, 0.4]", "[1, null]") text <- tool_text(result) # Cause 1 observed (TRUE), Cause 2 unknown (NA) # numerator = 0.8 * 0.3 = 0.24 # denominator = 0.8*0.3 + 0.2*0.7 = 0.38 # posterior = 0.24/0.38 = 0.631578... expect_true(grepl("0\\.631[0-9]", text)) expect_true(grepl("63\\.1[0-9]", text)) # 63.16% expect_true(grepl("Occurred", text)) expect_true(grepl("Unknown", text)) }) test_that("risk_post_prob_tool with all causes observed", { skip_if_not_installed("jsonlite") result <- PRA:::risk_post_prob_tool("[0.3, 0.2]", "[0.8, 0.6]", "[0.2, 0.4]", "[1, 0]") text <- tool_text(result) # Cause 1 observed=1: num *= 0.8*0.3=0.24, denom *= 0.38 # Cause 2 observed=0: num *= 0.4*0.8=0.32, denom *= 0.44 # posterior = (0.24*0.32) / (0.38*0.44) = 0.0768 / 0.1672 = 0.4593... expect_true(grepl("0\\.459[0-9]", text)) expect_true(grepl("Occurred", text)) expect_true(grepl("Did not occur", text)) }) test_that("sensitivity_tool returns 1.0 for independent tasks", { skip_if_not_installed("jsonlite") # For independent tasks, sensitivity = 1 for all tasks task_json <- '[{"type":"normal","mean":10,"sd":2},{"type":"triangular","a":5,"b":15,"c":10},{"type":"uniform","min":8,"max":12}]' result <- PRA:::sensitivity_tool(task_json) text <- tool_text(result) expect_true(grepl("Task 1", text)) expect_true(grepl("Task 2", text)) expect_true(grepl("Task 3", text)) # All sensitivities should be 1.0 for independent tasks # Extract the numeric values — each "1" or "1.0000" after task names lines <- strsplit(text, "\n")[[1]] task_lines <- lines[grepl("Task [0-9]", lines)] for (line in task_lines) { val <- as.numeric(trimws(sub(".*Task [0-9]+\\s+", "", line))) expect_equal(val, 1.0, tolerance = 0.001) } }) test_that("parent_dsm_tool computes correct S * t(S)", { skip_if_not_installed("jsonlite") # S = [[1,0,1],[0,1,0],[1,0,1]] # S * t(S) = [[2,0,2],[0,1,0],[2,0,2]] result <- PRA:::parent_dsm_tool("[[1,0,1],[0,1,0],[1,0,1]]") text <- tool_text(result) expect_true(grepl("Parent DSM", text)) # Check diagonal and off-diagonal values expect_true(grepl("2", text)) # diagonal elements [1,1] and [3,3] expect_true(grepl("1", text)) # diagonal element [2,2] }) test_that("grandparent_dsm_tool computes correct result", { skip_if_not_installed("jsonlite") # S = I(2), R = I(2) => grandparent = S * R * t(R) * t(S) = I(2) result <- PRA:::grandparent_dsm_tool("[[1,0],[0,1]]", "[[1,0],[0,1]]") text <- tool_text(result) expect_true(grepl("Grandparent DSM", text)) }) # ============================================================================ # MCS Numerical Tests (stochastic, use tolerances) # ============================================================================ test_that("mcs_tool produces statistically correct results", { skip_if_not_installed("jsonlite") set.seed(42) # Single normal task: Normal(100, 10) result <- PRA:::mcs_tool(50000, '[{"type":"normal","mean":100,"sd":10}]') text <- tool_text(result) # Mean should be close to 100 s <- PRA:::summarize_distribution(PRA:::.pra_agent_env$last_mcs$total_distribution) expect_equal(s$mean, 100, tolerance = 1) expect_equal(s$sd, 10, tolerance = 1) }) test_that("mcs_tool with multiple tasks sums correctly", { skip_if_not_installed("jsonlite") set.seed(42) # Normal(10,2) + Uniform(8,12): expected mean = 10 + 10 = 20 task_json <- '[{"type":"normal","mean":10,"sd":2},{"type":"uniform","min":8,"max":12}]' PRA:::mcs_tool(50000, task_json) s <- PRA:::summarize_distribution(PRA:::.pra_agent_env$last_mcs$total_distribution) expect_equal(s$mean, 20, tolerance = 0.5) # Uniform(8,12) variance = (12-8)^2/12 = 1.333, Normal var = 4, total var = 5.333 expect_equal(s$sd^2, 5.333, tolerance = 1) }) test_that("contingency_tool computes correct reserve from MCS", { skip_if_not_installed("jsonlite") set.seed(42) task_json <- '[{"type":"normal","mean":100,"sd":10}]' PRA:::mcs_tool(50000, task_json) result <- PRA:::contingency_tool(0.95, 0.50) text <- tool_text(result) expect_true(grepl("Contingency", text)) expect_true(grepl("P95", text)) expect_true(grepl("P50", text)) # For Normal(100,10): P95 - P50 ≈ 1.645*10 = 16.45 dist <- PRA:::.pra_agent_env$last_mcs$total_distribution expected_contingency <- unname(quantile(dist, 0.95) - quantile(dist, 0.50)) expect_true(grepl(format(round(expected_contingency, 4), big.mark = ","), text)) }) # ============================================================================ # Multi-Tool Chain Tests — Verify chaining state # ============================================================================ test_that("MCS -> contingency chain produces consistent results", { skip_if_not_installed("jsonlite") set.seed(123) task_json <- '[{"type":"normal","mean":50,"sd":5},{"type":"triangular","a":10,"b":20,"c":30}]' # Step 1: Run MCS mcs_result <- PRA:::mcs_tool(10000, task_json) mcs_text <- tool_text(mcs_result) expect_true(grepl("Monte Carlo Simulation", mcs_text)) # Verify MCS stored in environment expect_false(is.null(PRA:::.pra_agent_env$last_mcs)) dist <- PRA:::.pra_agent_env$last_mcs$total_distribution # Step 2: Run contingency cont_result <- PRA:::contingency_tool(0.95, 0.50) cont_text <- tool_text(cont_result) # Verify contingency matches the stored MCS distribution p95 <- unname(quantile(dist, 0.95)) p50 <- unname(quantile(dist, 0.50)) expected_reserve <- p95 - p50 expect_true(grepl(format(round(expected_reserve, 4), big.mark = ","), cont_text)) }) test_that("MCS -> sensitivity chain uses same distributions", { skip_if_not_installed("jsonlite") task_json <- '[{"type":"normal","mean":50,"sd":5},{"type":"uniform","min":10,"max":30}]' # Step 1: MCS PRA:::mcs_tool(5000, task_json) expect_false(is.null(PRA:::.pra_agent_env$last_mcs)) # Step 2: Sensitivity on same distributions sens_result <- PRA:::sensitivity_tool(task_json) sens_text <- tool_text(sens_result) expect_true(grepl("Task 1", sens_text)) expect_true(grepl("Task 2", sens_text)) }) test_that("cost_pdf -> cost_post_pdf chain works", { skip_if_not_installed("jsonlite") set.seed(42) # Step 1: Prior cost distribution prior_result <- PRA:::cost_pdf_tool(10000, "[0.3, 0.2]", "[50000, 30000]", "[10000, 5000]", 100000) prior_text <- tool_text(prior_result) expect_true(grepl("Prior Cost Distribution", prior_text)) expect_false(is.null(PRA:::.pra_agent_env$last_cost_pdf)) # Step 2: Posterior cost (risk 1 occurred, risk 2 unknown) post_result <- PRA:::cost_post_pdf_tool(10000, "[true, null]", "[50000, 30000]", "[10000, 5000]", 100000) post_text <- tool_text(post_result) expect_true(grepl("Posterior Cost Distribution", post_text)) # Posterior mean should be higher than prior mean (risk 1 definitely occurred) prior_dist <- PRA:::.pra_agent_env$last_cost_pdf post_dist <- PRA:::.pra_agent_env$last_cost_post_pdf expect_true(mean(post_dist) > mean(prior_dist)) }) # ============================================================================ # LLM-Style Input Pipeline Tests — Simulate realistic LLM tool calls # ============================================================================ test_that("pipeline: LLM sends string-wrapped numbers for EVM", { skip_if_not_installed("jsonlite") # LLMs often send numbers as strings in JSON result <- PRA:::evm_analysis_tool( bac = "100000", schedule_json = "[0.25, 0.5, 0.75, 1.0]", time_period = "2", actual_per_complete = "0.40", actual_costs_json = "[22000, 48000]", cumulative = "true" ) text <- tool_text(result) # PV = 100000 * 0.5 = 50,000 expect_true(grepl("50,000", text)) # EV = 100000 * 0.40 = 40,000 expect_true(grepl("40,000", text)) # AC = 48,000 expect_true(grepl("48,000", text)) # SV = 40000 - 50000 = -10,000 expect_true(grepl("-10,000", text)) }) test_that("pipeline: LLM sends integer percentage for EVM", { skip_if_not_installed("jsonlite") result <- PRA:::evm_analysis_tool( bac = 500000, schedule_json = "[0.2, 0.4, 0.6, 0.8, 1.0]", time_period = 3, actual_per_complete = 55, # 55% as integer actual_costs_json = "[95000, 200000, 310000]", cumulative = "true" ) text <- tool_text(result) # EV = 500000 * 0.55 = 275,000 expect_true(grepl("275,000", text)) # PV = 500000 * 0.6 = 300,000 expect_true(grepl("300,000", text)) # SV = 275000 - 300000 = -25,000 expect_true(grepl("-25,000", text)) }) test_that("pipeline: LLM sends bare numbers for risk_prob", { skip_if_not_installed("jsonlite") # LLM sends "0.3, 0.2" instead of "[0.3, 0.2]" result <- PRA:::risk_prob_tool("0.3, 0.2", "0.8, 0.6", "0.2, 0.4") text <- tool_text(result) expect_true(grepl("0\\.82", text)) }) test_that("pipeline: LLM sends trailing-comma JSON for SMM", { skip_if_not_installed("jsonlite") result <- PRA:::smm_tool("[10, 15, 20, 8,]", "[4, 9, 16, 2,]") text <- tool_text(result) expect_true(grepl("53", text)) # total mean expect_true(grepl("31", text)) # total variance }) test_that("pipeline: LLM sends R vectors directly (programmatic use)", { # Users calling tools from R code, not via LLM result <- PRA:::smm_tool( mean_json = c(10, 12, 8), var_json = c(4, 9, 2) ) text <- tool_text(result) # total_mean = 30, total_var = 15, total_std = sqrt(15) = 3.873 (rounded) expect_true(grepl("30", text)) expect_true(grepl("15", text)) expect_true(grepl("3\\.873", text)) }) test_that("pipeline: LLM sends R lists for MCS", { result <- PRA:::mcs_tool( num_sims = 1000, task_dists_json = list( list(type = "normal", mean = 10, sd = 2), list(type = "uniform", min = 8, max = 12) ) ) text <- tool_text(result) expect_true(grepl("Monte Carlo Simulation", text)) }) test_that("pipeline: LLM sends R vectors for risk_prob", { result <- PRA:::risk_prob_tool( cause_probs_json = c(0.3, 0.2), risks_given_causes_json = c(0.8, 0.6), risks_given_not_causes_json = c(0.2, 0.4) ) text <- tool_text(result) expect_true(grepl("0\\.82", text)) }) test_that("pipeline: LLM sends R vectors for EVM", { result <- PRA:::evm_analysis_tool( bac = 100000, schedule_json = c(0.1, 0.2, 0.4, 0.7, 1.0), time_period = 3, actual_per_complete = 0.35, actual_costs_json = c(10000, 22000, 37000), cumulative = TRUE ) text <- tool_text(result) expect_true(grepl("Earned Value Management", text)) expect_true(grepl("40,000", text)) # PV expect_true(grepl("-5,000", text)) # SV }) test_that("pipeline: LLM sends R matrix for DSM", { result <- PRA:::parent_dsm_tool(matrix(c(1, 0, 1, 0, 1, 0, 1, 0, 1), nrow = 3)) text <- tool_text(result) expect_true(grepl("Parent DSM", text)) }) test_that("pipeline: LLM sends R lists for sensitivity", { result <- PRA:::sensitivity_tool( task_dists_json = list( list(type = "normal", mean = 10, sd = 2), list(type = "triangular", a = 5, b = 15, c = 10) ) ) text <- tool_text(result) expect_true(grepl("Sensitivity", text)) }) # ============================================================================ # Learning Curve Numerical Tests # ============================================================================ test_that("fit_and_predict_sigmoidal_tool fits logistic model correctly", { skip_if_not_installed("jsonlite") x_json <- "[1,2,3,4,5,6,7,8,9,10]" y_json <- "[5,15,40,60,70,75,80,85,90,95]" result <- PRA:::fit_and_predict_sigmoidal_tool(x_json, y_json, "logistic") text <- tool_text(result) expect_true(grepl("Learning Curve Fit", text)) expect_true(grepl("logistic", text)) expect_true(grepl("Coefficients", text)) expect_true(grepl("Predictions", text)) }) test_that("fit_and_predict_sigmoidal_tool predictions at new x values", { skip_if_not_installed("jsonlite") x_json <- "[1,2,3,4,5,6,7,8,9,10]" y_json <- "[5,15,40,60,70,75,80,85,90,95]" result <- PRA:::fit_and_predict_sigmoidal_tool(x_json, y_json, "logistic", predict_x_json = "[12, 15]") text <- tool_text(result) # Predictions at x=12 and x=15 should exist expect_true(grepl("12", text)) expect_true(grepl("15", text)) }) # ============================================================================ # Cost PDF Numerical Tests # ============================================================================ test_that("cost_pdf_tool baseline cost is reflected in output", { skip_if_not_installed("jsonlite") set.seed(42) # With base_cost = 100000, mean should be >= 100000 result <- PRA:::cost_pdf_tool(10000, "[0.3, 0.2]", "[50000, 30000]", "[10000, 5000]", 100000) text <- tool_text(result) expect_true(grepl("100,000", text)) s <- PRA:::summarize_distribution(PRA:::.pra_agent_env$last_cost_pdf) # Mean should be > base cost (risks add cost) expect_true(s$mean > 100000) # Expected additional cost = 0.3*50000 + 0.2*30000 = 21000 # So mean should be around 121000 expect_equal(s$mean, 121000, tolerance = 5000) }) test_that("cost_post_pdf_tool with known risk produces higher mean", { skip_if_not_installed("jsonlite") set.seed(42) # Risk 1 occurred (TRUE), risk 2 did not (FALSE) result <- PRA:::cost_post_pdf_tool(10000, "[true, false]", "[50000, 30000]", "[10000, 5000]", 100000) text <- tool_text(result) s <- PRA:::summarize_distribution(PRA:::.pra_agent_env$last_cost_post_pdf) # Only risk 1 impact: mean should be around 100000 + 50000 = 150000 expect_equal(s$mean, 150000, tolerance = 2000) }) # ============================================================================ # MCS with Correlation # ============================================================================ test_that("mcs_tool with correlation matrix produces valid results", { skip_if_not_installed("jsonlite") set.seed(42) task_json <- '[{"type":"normal","mean":10,"sd":2},{"type":"normal","mean":12,"sd":3}]' cor_json <- "[[1,0.5],[0.5,1]]" result <- PRA:::mcs_tool(10000, task_json, cor_json) text <- tool_text(result) expect_true(grepl("Monte Carlo Simulation", text)) # Mean should still be 22 (correlation doesn't affect mean) s <- PRA:::summarize_distribution(PRA:::.pra_agent_env$last_mcs$total_distribution) expect_equal(s$mean, 22, tolerance = 0.5) # But variance should be higher than independent case (4 + 9 = 13) # With cor=0.5: var = 4 + 9 + 2*0.5*2*3 = 19 expect_true(s$sd^2 > 14) # Should be around 19 }) # ============================================================================ # Stores / Environment State Tests # ============================================================================ test_that("mcs_tool stores result for downstream tools", { skip_if_not_installed("jsonlite") task_json <- '[{"type":"normal","mean":10,"sd":2},{"type":"uniform","min":8,"max":12}]' PRA:::mcs_tool(1000, task_json) expect_false(is.null(PRA:::.pra_agent_env$last_mcs)) expect_s3_class(PRA:::.pra_agent_env$last_mcs, "mcs") }) test_that("cost_pdf_tool stores result for chaining", { skip_if_not_installed("jsonlite") PRA:::cost_pdf_tool(1000, "[0.3, 0.2]", "[50000, 30000]", "[10000, 5000]", 100000) expect_false(is.null(PRA:::.pra_agent_env$last_cost_pdf)) }) # ============================================================================ # /Command Framework Tests # ============================================================================ test_that("command registry returns all 9 commands", { registry <- PRA:::pra_command_registry() expect_type(registry, "list") expect_length(registry, 9) expected <- c( "mcs", "smm", "contingency", "sensitivity", "evm", "risk", "risk_post", "learning", "dsm" ) expect_equal(sort(names(registry)), sort(expected)) }) test_that("each command has required fields", { registry <- PRA:::pra_command_registry() for (nm in names(registry)) { cmd <- registry[[nm]] expect_true(!is.null(cmd$title), info = paste(nm, "missing title")) expect_true(!is.null(cmd$description), info = paste(nm, "missing description")) expect_true(!is.null(cmd$args), info = paste(nm, "missing args")) expect_true(!is.null(cmd$examples), info = paste(nm, "missing examples")) expect_true(is.function(cmd$fn), info = paste(nm, "missing fn")) } }) test_that("/help returns overview of all commands", { r <- PRA:::execute_command("/help") expect_true(r$ok) expect_true(grepl("PRA Commands", r$result)) expect_true(grepl("/mcs", r$result, fixed = TRUE)) expect_true(grepl("/evm", r$result, fixed = TRUE)) expect_true(grepl("/risk", r$result, fixed = TRUE)) }) test_that("/help returns detailed help", { r <- PRA:::execute_command("/help mcs") expect_true(r$ok) expect_true(grepl("Monte Carlo Simulation", r$result)) expect_true(grepl("tasks", r$result)) expect_true(grepl("Examples", r$result)) }) test_that("unknown command returns suggestions", { r <- PRA:::execute_command("/foobar") expect_false(r$ok) expect_true(grepl("Unknown command", r$result)) expect_true(grepl("/mcs", r$result, fixed = TRUE)) }) test_that("command with no args shows help when args are required", { r <- PRA:::execute_command("/evm") expect_true(r$ok) expect_true(grepl("bac", r$result)) expect_true(grepl("required", r$result, ignore.case = TRUE)) }) test_that("missing required args returns guidance", { r <- PRA:::execute_command("/risk causes=[0.3]") expect_false(r$ok) expect_true(grepl("Missing required", r$result)) expect_true(grepl("given", r$result)) expect_true(grepl("not_given", r$result)) }) test_that("/smm executes correctly", { skip_if_not_installed("jsonlite") r <- PRA:::execute_command("/smm means=[10,20,30] vars=[4,9,16]") expect_true(r$ok) expect_true(grepl("60", r$result)) # total mean expect_true(grepl("29", r$result)) # total variance }) test_that("/risk executes correctly with numerical verification", { skip_if_not_installed("jsonlite") r <- PRA:::execute_command("/risk causes=[0.3,0.2] given=[0.8,0.6] not_given=[0.2,0.4]") expect_true(r$ok) expect_true(grepl("0\\.82", r$result)) }) test_that("/evm executes correctly with numerical verification", { skip_if_not_installed("jsonlite") r <- PRA:::execute_command("/evm bac=500000 schedule=[0.2,0.4,0.6,0.8,1.0] period=3 complete=0.35 costs=[90000,195000,310000]") expect_true(r$ok) expect_true(grepl("300,000", r$result)) # PV expect_true(grepl("175,000", r$result)) # EV expect_true(grepl("-125,000", r$result)) # SV expect_true(grepl("0\\.5645", r$result)) # CPI }) test_that("/mcs executes and returns rich result", { skip_if_not_installed("jsonlite") r <- PRA:::execute_command('/mcs n=1000 tasks=[{"type":"normal","mean":10,"sd":2}]') expect_true(r$ok) expect_true(grepl("Monte Carlo", r$result)) expect_false(is.null(r$rich_result)) }) test_that("/contingency executes with defaults after /mcs", { skip_if_not_installed("jsonlite") # Run MCS first to populate last_mcs PRA:::execute_command('/mcs n=1000 tasks=[{"type":"normal","mean":10,"sd":2}]') r <- PRA:::execute_command("/contingency") expect_true(r$ok) expect_true(grepl("Contingency", r$result)) }) test_that("error in tool execution returns friendly message", { skip_if_not_installed("jsonlite") # Invalid JSON for tasks r <- PRA:::execute_command("/mcs tasks=not_valid_json") expect_false(r$ok) expect_true(grepl("Error running /mcs", r$result)) expect_true(grepl("/help mcs", r$result, fixed = TRUE)) }) test_that("format_command_help produces valid markdown", { registry <- PRA:::pra_command_registry() help_text <- PRA:::format_command_help("mcs", registry$mcs) expect_true(grepl("### /mcs", help_text)) expect_true(grepl("Arguments", help_text)) expect_true(grepl("Examples", help_text)) }) test_that("format_help_overview lists all commands", { overview <- PRA:::format_help_overview() registry <- PRA:::pra_command_registry() for (nm in names(registry)) { expect_true(grepl(paste0("/", nm), overview, fixed = TRUE), info = paste("Missing", nm, "in overview") ) } }) # ============================================================================ # Parser Edge Cases: bracket-aware argument parsing # ============================================================================ test_that("parse_command_args handles JSON with spaces", { skip_if_not_installed("jsonlite") parsed <- PRA:::parse_command_args( 'tasks=[{"type": "normal", "mean": 10, "sd": 2}]', "tasks" ) expect_equal(names(parsed), "tasks") # Value should contain the full JSON including spaces expect_true(grepl("normal", parsed$tasks)) expect_true(grepl("mean", parsed$tasks)) # Should parse as valid JSON obj <- jsonlite::fromJSON(parsed$tasks) expect_equal(obj$type, "normal") expect_equal(obj$mean, 10) }) test_that("parse_command_args handles multiple args with JSON spaces", { parsed <- PRA:::parse_command_args( 'n=5000 tasks=[{"type": "normal", "mean": 10, "sd": 2}]', c("n", "tasks") ) expect_equal(parsed$n, "5000") expect_true(grepl("normal", parsed$tasks)) }) test_that("parse_command_args handles nested brackets", { parsed <- PRA:::parse_command_args( 'matrix=[[1, 0, 1], [0, 1, 0], [1, 0, 1]]', "matrix" ) expect_true(grepl("\\[\\[1", parsed$matrix)) }) test_that("parse_command_args handles multiple JSON args with spaces", { parsed <- PRA:::parse_command_args( 'causes=[0.3, 0.2] given=[0.8, 0.6] not_given=[0.2, 0.4]', c("causes", "given", "not_given") ) expect_equal(length(parsed), 3) expect_true(grepl("0.3", parsed$causes)) expect_true(grepl("0.8", parsed$given)) expect_true(grepl("0.2", parsed$not_given)) }) test_that("parse_command_args handles empty string", { parsed <- PRA:::parse_command_args("", c("x", "y")) expect_equal(length(parsed), 0) }) test_that("parse_command_args handles NULL", { parsed <- PRA:::parse_command_args(NULL, c("x")) expect_equal(length(parsed), 0) }) test_that("parse_command_args handles quoted strings in JSON", { parsed <- PRA:::parse_command_args( 'tasks=[{"type": "normal", "mean": 10}] n=500', c("tasks", "n") ) expect_equal(parsed$n, "500") expect_true(grepl('"type"', parsed$tasks)) }) test_that("parse_command_args handles args appearing after JSON", { # n comes after the JSON arg parsed <- PRA:::parse_command_args( 'tasks=[{"type": "uniform", "min": 5, "max": 15}] n=1000', c("n", "tasks") ) expect_equal(parsed$n, "1000") expect_true(grepl("uniform", parsed$tasks)) }) # ============================================================================ # /command execution with spaces in JSON (integration) # ============================================================================ test_that("/mcs works with spaces in JSON values", { skip_if_not_installed("jsonlite") r <- PRA:::execute_command( '/mcs tasks=[{"type": "normal", "mean": 10, "sd": 2}]' ) expect_true(r$ok) expect_true(grepl("Monte Carlo", r$result)) }) test_that("/mcs works with multiple tasks and spaces", { skip_if_not_installed("jsonlite") r <- PRA:::execute_command( '/mcs n=1000 tasks=[{"type": "normal", "mean": 10, "sd": 2}, {"type": "uniform", "min": 5, "max": 15}]' ) expect_true(r$ok) expect_true(grepl("Monte Carlo", r$result)) }) test_that("/evm works with spaces in JSON arrays", { skip_if_not_installed("jsonlite") r <- PRA:::execute_command( "/evm bac=500000 schedule=[0.2, 0.4, 0.6, 0.8, 1.0] period=3 complete=0.35 costs=[90000, 195000, 310000]" ) expect_true(r$ok) expect_true(grepl("300,000", r$result)) }) test_that("/risk works with spaces in arrays", { skip_if_not_installed("jsonlite") r <- PRA:::execute_command( "/risk causes=[0.3, 0.2] given=[0.8, 0.6] not_given=[0.2, 0.4]" ) expect_true(r$ok) expect_true(grepl("0\\.82", r$result)) }) test_that("/smm works with spaces in arrays", { skip_if_not_installed("jsonlite") r <- PRA:::execute_command("/smm means=[10, 12, 8] vars=[4, 9, 2]") expect_true(r$ok) expect_true(grepl("30", r$result)) }) test_that("/dsm works with nested brackets and spaces", { skip_if_not_installed("jsonlite") r <- PRA:::execute_command("/dsm matrix=[[1, 1, 0], [0, 1, 1], [1, 0, 1]]") expect_true(r$ok) expect_true(grepl("Parent DSM", r$result)) }) test_that("/sensitivity works with spaces in task JSON", { skip_if_not_installed("jsonlite") r <- PRA:::execute_command( '/sensitivity tasks=[{"type": "normal", "mean": 10, "sd": 2}, {"type": "triangular", "a": 5, "b": 10, "c": 15}]' ) expect_true(r$ok) expect_true(grepl("Sensitivity", r$result)) }) # ============================================================================ # Multi-turn state / chaining tests # ============================================================================ test_that("contingency chains correctly after mcs via /commands", { skip_if_not_installed("jsonlite") env <- PRA:::.pra_agent_env old_mcs <- env$last_mcs env$last_mcs <- NULL # Run MCS r1 <- PRA:::execute_command('/mcs n=1000 tasks=[{"type": "normal", "mean": 20, "sd": 3}]') expect_true(r1$ok) expect_false(is.null(env$last_mcs)) # Chain to contingency r2 <- PRA:::execute_command("/contingency phigh=0.90 pbase=0.50") expect_true(r2$ok) expect_true(grepl("Contingency", r2$result)) expect_true(grepl("P90", r2$result)) expect_true(grepl("P50", r2$result)) env$last_mcs <- old_mcs }) test_that("contingency returns guidance without prior mcs", { skip_if_not_installed("jsonlite") env <- PRA:::.pra_agent_env old_mcs <- env$last_mcs env$last_mcs <- NULL r <- PRA:::execute_command("/contingency") # Tool returns a friendly error message (not a crash) expect_true(grepl("mcs_tool", r$result, ignore.case = TRUE)) env$last_mcs <- old_mcs }) test_that("cost_pdf chains to cost_post_pdf via tool wrappers", { skip_if_not_installed("jsonlite") env <- PRA:::.pra_agent_env old_cost <- env$last_cost_pdf env$last_cost_pdf <- NULL # Prior cost distribution PRA:::cost_pdf_tool(1000, "[0.3, 0.2]", "[50000, 30000]", "[10000, 5000]", 100000) expect_false(is.null(env$last_cost_pdf)) # Posterior cost distribution result <- PRA:::cost_post_pdf_tool(1000, "[1, 0]", "[50000, 30000]", "[10000, 5000]", 100000) text <- tool_text(result) expect_true(grepl("Posterior", text)) env$last_cost_pdf <- old_cost }) # ============================================================================ # Routing logic tests # ============================================================================ test_that("execute_command routes /help correctly", { r <- PRA:::execute_command("/help") expect_true(r$ok) expect_true(grepl("PRA Commands", r$result)) }) test_that("execute_command routes /help correctly", { r <- PRA:::execute_command("/help evm") expect_true(r$ok) expect_true(grepl("Earned Value", r$result)) expect_true(grepl("bac", r$result)) }) test_that("execute_command routes /help correctly", { r <- PRA:::execute_command("/help foobar") expect_false(r$ok) expect_true(grepl("Unknown command", r$result)) expect_true(grepl("PRA Commands", r$result)) }) test_that("execute_command handles leading/trailing whitespace", { r <- PRA:::execute_command(" /smm means=[10,20] vars=[4,9] ") expect_true(r$ok) expect_true(grepl("30", r$result)) }) test_that("execute_command is case-insensitive for command names", { r <- PRA:::execute_command("/SMM means=[10,20] vars=[4,9]") expect_true(r$ok) expect_true(grepl("30", r$result)) }) test_that("all 9 commands have consistent registry structure", { registry <- PRA:::pra_command_registry() for (nm in names(registry)) { cmd <- registry[[nm]] # Each arg must have all required fields for (a in cmd$args) { expect_true(!is.null(a$name), info = paste(nm, "arg missing name")) expect_true(!is.null(a$type), info = paste(nm, a$name, "missing type")) expect_true(!is.null(a$required), info = paste(nm, a$name, "missing required")) expect_true(!is.null(a$help), info = paste(nm, a$name, "missing help")) expect_true(a$type %in% c("integer", "number", "string", "json"), info = paste(nm, a$name, "invalid type:", a$type)) } # Examples must be non-empty and start with / expect_true(length(cmd$examples) > 0, info = paste(nm, "missing examples")) for (ex in cmd$examples) { expect_true(grepl("^/", ex), info = paste(nm, "example doesn't start with /:", ex)) } } }) # ============================================================================ # Three-Mode Routing Tests # ============================================================================ test_that("route_input classifies /commands as 'command' mode", { r <- PRA:::route_input("/mcs tasks=[...]") expect_equal(r$mode, "command") expect_equal(r$command, "mcs") r <- PRA:::route_input("/help") expect_equal(r$mode, "command") expect_equal(r$command, "help") r <- PRA:::route_input("/evm bac=500000") expect_equal(r$mode, "command") expect_equal(r$command, "evm") }) test_that("route_input classifies numerical data as 'tool' mode", { # Distribution specifications r <- PRA:::route_input("Run a simulation with Normal(10, 2) and Triangular(5, 10, 15)") expect_equal(r$mode, "tool") # Numeric arrays r <- PRA:::route_input("Means are [10, 15, 20] and variances are [4, 9, 16]") expect_equal(r$mode, "tool") # Dollar amounts with BAC r <- PRA:::route_input("My project has BAC = $500,000 with schedule [0.2, 0.4]") expect_equal(r$mode, "tool") # Simulation count r <- PRA:::route_input("Run 10000 simulations for a 3-task project") expect_equal(r$mode, "tool") # Probabilities r <- PRA:::route_input("P(C1) = 0.3, P(Risk|C1) = 0.8") expect_equal(r$mode, "tool") # Schedule/costs arrays r <- PRA:::route_input("Actual costs [90000, 195000, 310000]") expect_equal(r$mode, "tool") }) test_that("route_input classifies conceptual questions as 'rag' mode", { r <- PRA:::route_input("What is earned value management?") expect_equal(r$mode, "rag") r <- PRA:::route_input("Explain the difference between SPI and CPI") expect_equal(r$mode, "rag") r <- PRA:::route_input("How does Monte Carlo simulation work?") expect_equal(r$mode, "rag") r <- PRA:::route_input("What are the benefits of Bayesian risk analysis?") expect_equal(r$mode, "rag") r <- PRA:::route_input("Tell me about contingency reserves") expect_equal(r$mode, "rag") }) test_that("route_input returns required fields", { for (input in c("/help", "Normal(10,2)", "What is EVM?")) { r <- PRA:::route_input(input) expect_true("mode" %in% names(r)) expect_true("reason" %in% names(r)) expect_true(r$mode %in% c("command", "tool", "rag")) expect_true(nchar(r$reason) > 0) } }) test_that("route_input handles whitespace", { r <- PRA:::route_input(" /mcs tasks=[] ") expect_equal(r$mode, "command") }) # ============================================================================ # Utility functions: check_package, save_pra_plot, tool_result, html helpers # ============================================================================ test_that("check_package errors with purpose message", { expect_error( PRA:::check_package("nonexistent_fake_pkg", purpose = "testing"), "required.*testing" ) expect_error( PRA:::check_package("nonexistent_fake_pkg"), "required" ) }) test_that("save_pra_plot creates a PNG file", { plot_fn <- function() plot(1:5, 1:5, main = "test") path <- PRA:::save_pra_plot(plot_fn, "test_coverage") expect_true(file.exists(path)) expect_true(grepl("\\.png$", path)) expect_true(file.info(path)$size > 0) unlink(path) }) test_that("plot_to_html returns base64 img tag", { skip_if_not_installed("base64enc") plot_fn <- function() plot(1:5, 1:5, main = "test") html <- PRA:::plot_to_html(plot_fn) expect_true(grepl("^