diff --git a/NAMESPACE b/NAMESPACE index 5d956a0..f5e7f45 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -14,3 +14,4 @@ export(cog_peer_compare) export(cog_recipes) export(cog_revenue) export(cog_spending) +export(cog_suggestions) diff --git a/NEWS.md b/NEWS.md index ea1282e..9fe20d1 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,37 @@ +# uscogdata (development version) + +## `cog_suggestions()` builds coverage suggestions without a second verb call + +`cog_spending()` and `cog_revenue()` attach harmonization-recipe suggestions as +`attr(x, "provenance")$suggestions` when you name a category. `cog_suggestions()` +returns the same list for a set of governments, years, categories, a flow and a +concept, given the years your rows cover. You can call a verb once with no +`category`, filter its rows yourself, and build the suggestions for one category +only when you need them. + +```r +govids <- c("061037123085", "121011212191") +spend <- cog_spending(govids, years = 2011:2012) + +corrections <- spend[spend$category == "Corrections", ] +cog_suggestions( + govids, years = 2011:2012, category = "Corrections", + flow = "spending", years_present = unique(corrections$year) +) +``` + +`cog_suggestions()` checks its arguments before it opens a session and raises +`cli` errors for a malformed call. It returns the list without printing the +message the verbs print. + +## Suggestions come back in a stable order + +`cog_spending()`, `cog_revenue()` and `cog_suggestions()` order suggestions by +`recipe_id`. The query that picks candidate recipes used `SELECT DISTINCT` with no +`ORDER BY`, so DuckDB returned them in whatever order its parallel aggregate +finished, and two identical calls could list the same suggestions in different +orders. The query now sorts them. + # uscogdata 0.4.0 ## Cohorts can be named by predicate, not just by id diff --git a/R/suggestions.R b/R/suggestions.R index ca7c40d..6e66d2e 100644 --- a/R/suggestions.R +++ b/R/suggestions.R @@ -38,6 +38,202 @@ # an IG row surviving on a legacy aggregate that Direct excludes must not # read as coverage and cancel the very suggestion that would recover it. +#' Coverage suggestions for a spending or revenue query +#' +#' Builds the harmonization-recipe suggestions that [cog_spending()] and +#' [cog_revenue()] attach as `attr(x, "provenance")$suggestions`, without +#' running the verb again. Call a verb once with no `category`, filter its +#' rows yourself, and call this when you need the suggestions for one +#' category. +#' +#' A suggestion names a recipe (see [cog_recipes()]) that would fill a gap in +#' the result. A recipe qualifies in two ways. Its `trigger` is +#' `"empty_year"` when the result has no rows in a requested year and the +#' recipe's own join finds rows for these governments in that year. Its +#' `trigger` is `"suppressed_component"` when one of the recipe's component +#' codes carries dollars that the verb's own view excludes, because Census +#' published them only as an aggregate or because the code is absent from +#' `summary_categories`. +#' +#' Suggestions are built only for the verbs' default `basis = "harmonized"`. +#' On a corpus with `schema_version < 5` that basis is unavailable, the verbs +#' fall back to `"raw"`, and this function returns an empty list, as the +#' verbs do. It also returns an empty list when nothing qualifies. +#' +#' The verbs also print the suggestions as a message. This function returns +#' them without printing. +#' +#' @param govid Character vector of `canonical_govid` values, or a data frame +#' with a `canonical_govid` column, such as a [cog_find_peers()] or +#' [cog_gov_search()] result. The suggestions cover these governments as one +#' cohort, as they do in a verb call over the same `govid`. +#' @param years Integer vector of requested years. +#' @param category Character vector of category names from +#' `summary_categories.category`, or the reserved `"All Categories"` alone. +#' The verbs build suggestions only when a category is named, so `NULL` is +#' refused here. +#' @param flow `"spending"` for the suggestions [cog_spending()] would attach, +#' or `"revenue"` for [cog_revenue()]'s. +#' @param concept The verb's concept argument: `expenditure_concept` for +#' spending (`"primary"`, `"direct"` or `"total"`) or `revenue_concept` for +#' revenue (`"general"` or `"total"`). `NULL`, the default, uses the verb's +#' own default, `"primary"` or `"general"`. The concept changes the result +#' only when `category = "All Categories"`, where its subtype scope decides +#' which recipes are candidates. +#' @param years_present Integer vector of the years in which your rows for +#' `category` have at least one row. Pass `integer(0)` when there are no +#' rows. Years outside `years` are ignored. With `flow = "spending"` and +#' `concept = "total"`, count only rows whose `spend_subtype` is not +#' `"intergovernmental"`. [cog_spending()] measures gaps on the Direct leg +#' alone, because an intergovernmental row can survive in a year whose +#' Direct rows Census published only as an aggregate. +#' @return A list with one element per suggestion, ordered by `recipe_id`, +#' and identical to `attr(x, "provenance")$suggestions` for the same +#' inputs. Each element is a list with `recipe_id`, `label`, +#' `available_years` (first and last year the recipe covers), `hint`, +#' `trigger` (`"empty_year"` or `"suppressed_component"`), +#' `suppressed_amount` (full US dollars), `suppressed_years`, +#' `suppressed_codes`, and `ig_recipe_id` (the intergovernmental +#' counterpart recipe, or `NULL`). The list is empty when nothing qualifies. +#' @seealso [cog_spending()], [cog_revenue()], [cog_recipes()], +#' [cog_explain()] +#' @examples +#' \dontrun{ +#' govids <- c("061037123085", "121011212191") +#' spend <- cog_spending(govids, years = 2011:2012) +#' +#' corrections <- spend[spend$category == "Corrections", ] +#' cog_suggestions( +#' govids, years = 2011:2012, category = "Corrections", +#' flow = "spending", years_present = unique(corrections$year) +#' ) +#' } +#' @export +cog_suggestions <- function(govid, years, category, + flow = c("spending", "revenue"), + concept = NULL, years_present) { + args <- .validate_suggestion_inputs(govid, years, category, flow, concept, + years_present) + + con <- .ensure_session() + # The verbs' default basis, resolved the way .verb_spendrev() resolves it + # when the caller leaves `basis` unset: "raw" on a schema_version < 5 + # corpus, where .build_suggestions() returns list() before touching the + # harmonization tables that corpus does not have. + resolved <- .resolve_basis("harmonized", explicit = FALSE, + .uscogdata_env$manifest) + + .build_suggestions( + con, .make_cohort(args$govid), args$years, args$category, + data.frame(year = args$years_present), resolved$basis, + args$spec$flow_prefixes, + .select_long_view(args$spec$view_base, resolved$basis), + all_categories = args$all_categories, + subtype_col = args$spec$subtype_col, + subtype_scope = args$subtype_scope + ) +} + +# What cog_spending() and cog_revenue() pass to .verb_spendrev() for the +# arguments .build_suggestions() reads. tests/testthat/test-suggestions.R +# checks that cog_suggestions() returns what each verb attaches, so a change +# to either verb's flow family that is not mirrored here fails there. +.SUGGESTION_FLOWS <- list( + spending = list(view_base = "spending_annotated", + subtype_col = "spend_subtype", + flow_prefixes = c("E", "F", "G"), + concepts = c("primary", "direct", "total"), + concept_arg = "expenditure_concept"), + revenue = list(view_base = "revenue_annotated", + subtype_col = "revenue_subtype", + flow_prefixes = c("T", "A", "U", "B", "C", "D"), + concepts = c("general", "total"), + concept_arg = "revenue_concept") +) + +#' Validate and normalize `cog_suggestions()` arguments. +#' +#' Every check runs before `.ensure_session()`, so a malformed call costs no +#' I/O. Returns the coerced inputs plus the flow's spec from +#' `.SUGGESTION_FLOWS` and the concept's subtype scope. +#' @noRd +.validate_suggestion_inputs <- function(govid, years, category, flow, + concept, years_present) { + if (missing(govid)) cli::cli_abort("{.arg govid} is required.") + if (missing(years)) cli::cli_abort("{.arg years} is required.") + if (missing(category)) cli::cli_abort("{.arg category} is required.") + if (missing(years_present)) { + cli::cli_abort(c( + "{.arg years_present} is required.", + "i" = "Pass the years in which your rows for {.arg category} have at least one row, or {.code integer(0)} when there are none." + )) + } + + govid <- .coerce_govid_input(govid, arg = "govid") + if (length(govid) == 0L || anyNA(govid) || !all(nzchar(govid))) { + cli::cli_abort("{.arg govid} must name at least one government, with no missing or empty ids.") + } + + if (!is.numeric(years) || length(years) == 0L || anyNA(years)) { + cli::cli_abort("{.arg years} must be a non-empty integer vector with no missing values.") + } + + if (is.null(category)) { + cli::cli_abort(c( + "{.arg category} must name at least one category.", + "i" = "The verbs build suggestions only when a category is named, so a {.code NULL} category has none." + )) + } + if (!is.character(category) || length(category) == 0L || anyNA(category)) { + cli::cli_abort("{.arg category} must be a non-empty character vector with no missing values.") + } + all_categories <- .ALL_CATEGORIES %in% category + if (all_categories && length(category) > 1L) { + cli::cli_abort(c( + "{.val {(.ALL_CATEGORIES)}} cannot be combined with other categories.", + "i" = "It already covers every category in the requested concept's scope.", + "*" = "Ask for it alone, or list the specific categories you want." + ), class = "uscogdata_all_categories_not_combinable") + } + + flow <- tryCatch( + match.arg(flow, names(.SUGGESTION_FLOWS)), + error = function(e) { + cli::cli_abort( + "{.arg flow} must be one of {.val spending} or {.val revenue}.", + class = "uscogdata_invalid_flow", parent = e + ) + } + ) + spec <- .SUGGESTION_FLOWS[[flow]] + + if (is.null(concept)) concept <- spec$concepts[[1]] + if (!is.character(concept) || length(concept) != 1L || + !concept %in% spec$concepts) { + cli::cli_abort( + "With {.code flow = \"{flow}\"}, {.arg concept} must be {.code NULL} or one of {.val {spec$concepts}}.", + class = paste0("uscogdata_invalid_", spec$concept_arg) + ) + } + subtype_scope <- if (identical(flow, "spending")) { + .expenditure_concept_subtypes(concept) + } else { + .revenue_concept_subtypes(concept) + } + + if (!is.numeric(years_present) || anyNA(years_present)) { + cli::cli_abort(c( + "{.arg years_present} must be an integer vector with no missing values.", + "i" = "Use {.code integer(0)} when your rows cover no years." + )) + } + + list(govid = govid, years = as.integer(years), category = category, + all_categories = all_categories, spec = spec, + subtype_scope = subtype_scope, + years_present = as.integer(years_present)) +} + #' Build the `prov$suggestions` list for a (non-recipe) basis = "harmonized" #' verb call: recipes whose generic join would fill a real gap in `result`. #' @@ -241,7 +437,12 @@ #' scope by when `all_categories = TRUE`; ignored otherwise. #' @param subtype_scope Character vector of subtype values to scope by #' when `all_categories = TRUE`; ignored otherwise. -#' @return Character vector of recipe IDs (possibly empty). +#' @return Character vector of recipe IDs (possibly empty), sorted by +#' `recipe_id`. `.build_suggestions()` emits suggestions in this order, so +#' the sort is what makes two identical calls return the same list in the +#' same order (uscogdata#75). Without the `ORDER BY`, DuckDB returns +#' `SELECT DISTINCT` rows in whatever order its parallel hash aggregate +#' finishes in. #' @noRd .query_candidate_recipes <- function(con, category, flow_prefixes, all_categories = FALSE, @@ -279,7 +480,8 @@ AND recipe_id NOT IN ( SELECT DISTINCT recipe_id FROM harmonization_recipes WHERE LEFT(component_code, 1) IN ('M', 'L') - )", + ) + ORDER BY recipe_id", candidate_scope_sql ))$recipe_id } diff --git a/_pkgdown.yml b/_pkgdown.yml index 057bc65..b9efa2b 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -29,6 +29,7 @@ reference: contents: - cog_categories - cog_recipes + - cog_suggestions - cog_manifest - cog_explain - cog_mirror diff --git a/man/cog_suggestions.Rd b/man/cog_suggestions.Rd new file mode 100644 index 0000000..7a8ca12 --- /dev/null +++ b/man/cog_suggestions.Rd @@ -0,0 +1,97 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/suggestions.R +\name{cog_suggestions} +\alias{cog_suggestions} +\title{Coverage suggestions for a spending or revenue query} +\usage{ +cog_suggestions( + govid, + years, + category, + flow = c("spending", "revenue"), + concept = NULL, + years_present +) +} +\arguments{ +\item{govid}{Character vector of `canonical_govid` values, or a data frame +with a `canonical_govid` column, such as a [cog_find_peers()] or +[cog_gov_search()] result. The suggestions cover these governments as one +cohort, as they do in a verb call over the same `govid`.} + +\item{years}{Integer vector of requested years.} + +\item{category}{Character vector of category names from +`summary_categories.category`, or the reserved `"All Categories"` alone. +The verbs build suggestions only when a category is named, so `NULL` is +refused here.} + +\item{flow}{`"spending"` for the suggestions [cog_spending()] would attach, +or `"revenue"` for [cog_revenue()]'s.} + +\item{concept}{The verb's concept argument: `expenditure_concept` for +spending (`"primary"`, `"direct"` or `"total"`) or `revenue_concept` for +revenue (`"general"` or `"total"`). `NULL`, the default, uses the verb's +own default, `"primary"` or `"general"`. The concept changes the result +only when `category = "All Categories"`, where its subtype scope decides +which recipes are candidates.} + +\item{years_present}{Integer vector of the years in which your rows for +`category` have at least one row. Pass `integer(0)` when there are no +rows. Years outside `years` are ignored. With `flow = "spending"` and +`concept = "total"`, count only rows whose `spend_subtype` is not +`"intergovernmental"`. [cog_spending()] measures gaps on the Direct leg +alone, because an intergovernmental row can survive in a year whose +Direct rows Census published only as an aggregate.} +} +\value{ +A list with one element per suggestion, ordered by `recipe_id`, + and identical to `attr(x, "provenance")$suggestions` for the same + inputs. Each element is a list with `recipe_id`, `label`, + `available_years` (first and last year the recipe covers), `hint`, + `trigger` (`"empty_year"` or `"suppressed_component"`), + `suppressed_amount` (full US dollars), `suppressed_years`, + `suppressed_codes`, and `ig_recipe_id` (the intergovernmental + counterpart recipe, or `NULL`). The list is empty when nothing qualifies. +} +\description{ +Builds the harmonization-recipe suggestions that [cog_spending()] and +[cog_revenue()] attach as `attr(x, "provenance")$suggestions`, without +running the verb again. Call a verb once with no `category`, filter its +rows yourself, and call this when you need the suggestions for one +category. +} +\details{ +A suggestion names a recipe (see [cog_recipes()]) that would fill a gap in +the result. A recipe qualifies in two ways. Its `trigger` is +`"empty_year"` when the result has no rows in a requested year and the +recipe's own join finds rows for these governments in that year. Its +`trigger` is `"suppressed_component"` when one of the recipe's component +codes carries dollars that the verb's own view excludes, because Census +published them only as an aggregate or because the code is absent from +`summary_categories`. + +Suggestions are built only for the verbs' default `basis = "harmonized"`. +On a corpus with `schema_version < 5` that basis is unavailable, the verbs +fall back to `"raw"`, and this function returns an empty list, as the +verbs do. It also returns an empty list when nothing qualifies. + +The verbs also print the suggestions as a message. This function returns +them without printing. +} +\examples{ +\dontrun{ +govids <- c("061037123085", "121011212191") +spend <- cog_spending(govids, years = 2011:2012) + +corrections <- spend[spend$category == "Corrections", ] +cog_suggestions( + govids, years = 2011:2012, category = "Corrections", + flow = "spending", years_present = unique(corrections$year) +) +} +} +\seealso{ +[cog_spending()], [cog_revenue()], [cog_recipes()], + [cog_explain()] +} diff --git a/tests/testthat/test-suggestions.R b/tests/testthat/test-suggestions.R new file mode 100644 index 0000000..9363454 --- /dev/null +++ b/tests/testthat/test-suggestions.R @@ -0,0 +1,281 @@ +# tests/testthat/test-suggestions.R +# +# cog_suggestions() (uscogdata#75): the exported form of .build_suggestions(). +# Each equivalence test runs a verb with a category named, takes the years its +# rows cover, and checks that cog_suggestions() returns exactly the list the +# verb attached as provenance$suggestions. + +verb_suggestions <- function(r) attr(r, "provenance")$suggestions + +suggestion_ids <- function(sugg) { + vapply(sugg, function(s) s$recipe_id, character(1)) +} + +test_that("cog_suggestions() matches cog_spending() on an empty_year fire", { + skip_if_no_corpus() + r <- suppressMessages( + cog_spending("061037123085", years = 2011L, category = "Corrections")) + expect_length(verb_suggestions(r), 3L) + + s <- cog_suggestions("061037123085", years = 2011L, category = "Corrections", + flow = "spending", years_present = unique(r$year)) + expect_identical(s, verb_suggestions(r)) + expect_identical( + suggestion_ids(s), + c("corrections_capital_combined", "corrections_combined", + "corrections_other_capital_combined")) +}) + +test_that("cog_suggestions() matches cog_spending() on a suppressed_component fire", { + skip_if_no_corpus() + r <- suppressMessages( + cog_spending("061037123085", years = 2011L, category = "Public Welfare")) + expect_gt(nrow(r), 0L) + expect_length(verb_suggestions(r), 2L) + + s <- cog_suggestions("061037123085", years = 2011L, + category = "Public Welfare", flow = "spending", + years_present = unique(r$year)) + expect_identical(s, verb_suggestions(r)) +}) + +test_that("cog_suggestions() matches cog_spending() for a multi-government cohort", { + skip_if_no_corpus() + govids <- c("061037123085", "121011212191", "010000226085") + r <- suppressMessages( + cog_spending(govids, years = c(2011L, 2012L), category = "Corrections")) + expect_gt(length(verb_suggestions(r)), 0L) + + s <- cog_suggestions(govids, years = c(2011L, 2012L), + category = "Corrections", flow = "spending", + years_present = unique(r$year)) + expect_identical(s, verb_suggestions(r)) +}) + +test_that("cog_suggestions() accepts a data frame of governments", { + skip_if_no_corpus() + r <- suppressMessages( + cog_spending("061037123085", years = 2011L, category = "Corrections")) + s <- cog_suggestions(data.frame(canonical_govid = "061037123085"), + years = 2011L, category = "Corrections", + years_present = unique(r$year)) + expect_identical(s, verb_suggestions(r)) +}) + +test_that("cog_suggestions() matches cog_revenue()", { + skip_if_no_corpus() + r <- suppressMessages( + cog_revenue("020000227749", years = 2011L, + category = "Miscellaneous Revenue")) + expect_length(verb_suggestions(r), 1L) + + s <- cog_suggestions("020000227749", years = 2011L, + category = "Miscellaneous Revenue", flow = "revenue", + years_present = unique(r$year)) + expect_identical(s, verb_suggestions(r)) +}) + +test_that("cog_suggestions() matches cog_revenue() under revenue_concept = 'total'", { + skip_if_no_corpus() + r <- suppressMessages( + cog_revenue("020000227749", years = 2011L, + category = "Miscellaneous Revenue", revenue_concept = "total")) + s <- cog_suggestions("020000227749", years = 2011L, + category = "Miscellaneous Revenue", flow = "revenue", + concept = "total", years_present = unique(r$year)) + expect_identical(s, verb_suggestions(r)) +}) + +test_that("cog_suggestions() matches the verbs for 'All Categories' under each concept", { + skip_if_no_corpus() + gov <- "010000226085" + for (concept in c("primary", "direct")) { + r <- suppressMessages( + cog_spending(gov, 2011L, category = "All Categories", + expenditure_concept = concept)) + expect_gt(length(verb_suggestions(r)), 0L) + s <- cog_suggestions(gov, 2011L, category = "All Categories", + concept = concept, years_present = unique(r$year)) + expect_identical(s, verb_suggestions(r), info = concept) + } + + r <- suppressMessages( + cog_revenue(gov, 2011L, category = "All Categories", + revenue_concept = "total")) + s <- cog_suggestions(gov, 2011L, category = "All Categories", + flow = "revenue", concept = "total", + years_present = unique(r$year)) + expect_identical(s, verb_suggestions(r)) +}) + +test_that("cog_suggestions() matches expenditure_concept = 'total' given Direct-leg years", { + # cog_spending() measures gaps on the Direct leg only (C1(a) in + # R/suggestions.R), so the caller passes the years with a + # non-intergovernmental row, as the years_present documentation says. + skip_if_no_corpus() + r <- suppressMessages( + cog_spending("061037123085", years = 2011L, category = "Corrections", + expenditure_concept = "total")) + expect_gt(length(verb_suggestions(r)), 0L) + + direct <- r[r$spend_subtype != "intergovernmental", ] + s <- cog_suggestions("061037123085", years = 2011L, + category = "Corrections", concept = "total", + years_present = unique(direct$year)) + expect_identical(s, verb_suggestions(r)) +}) + +test_that("cog_suggestions() returns an empty list when nothing qualifies", { + skip_if_no_corpus() + r <- cog_spending("121011212191", years = 2019:2020, category = "Corrections") + expect_length(verb_suggestions(r), 0L) + s <- cog_suggestions("121011212191", years = 2019:2020, + category = "Corrections", + years_present = unique(r$year)) + expect_identical(s, list()) +}) + +test_that("cog_suggestions() follows the verbs to basis = 'raw' on a schema_version 4 corpus", { + skip_if_no_corpus() + with_doctored_schema_version(4L, { + expect_identical( + cog_suggestions("061037123085", years = 2011L, + category = "Corrections", years_present = integer(0)), + list()) + }) +}) + +test_that("cog_suggestions() prints nothing", { + skip_if_no_corpus() + uscogdata:::.ensure_session() + expect_silent( + cog_suggestions("061037123085", years = 2011L, category = "Corrections", + years_present = integer(0))) +}) + +# --- stable order ------------------------------------------------------------ + +test_that("suggestions come back ordered by recipe_id, in the verbs and here", { + # On the fixture, the unsorted candidate query lists + # corrections_other_capital_combined before corrections_combined, so this + # fails without the ORDER BY in .query_candidate_recipes(). + skip_if_no_corpus() + r <- suppressMessages( + cog_spending("010000226085", 2011L, category = "All Categories")) + ids <- suggestion_ids(verb_suggestions(r)) + expect_gt(length(ids), 1L) + expect_identical(ids, sort(ids, method = "radix")) + + s <- cog_suggestions("010000226085", 2011L, category = "All Categories", + years_present = unique(r$year)) + expect_identical(suggestion_ids(s), ids) +}) + +test_that("repeated cog_suggestions() calls return the same list in the same order", { + skip_if_no_corpus() + calls <- lapply(seq_len(10L), function(i) { + cog_suggestions("010000226085", 2011L, category = "All Categories", + years_present = 2011L) + }) + expect_gt(length(calls[[1]]), 1L) + for (i in seq_along(calls)[-1L]) { + expect_identical(calls[[i]], calls[[1]], info = paste("call", i)) + } +}) + +test_that(".query_candidate_recipes() returns recipe ids in sorted order", { + con <- uscogdata:::.ensure_session() + ids <- uscogdata:::.query_candidate_recipes( + con, category = NULL, flow_prefixes = c("E", "F", "G"), + all_categories = TRUE, subtype_col = "spend_subtype", + subtype_scope = c("operations", "capital", "assistance")) + expect_gt(length(ids), 1L) + expect_identical(ids, sort(ids, method = "radix")) +}) + +# --- input validation -------------------------------------------------------- + +test_that("cog_suggestions() requires every argument without a default", { + expect_error( + cog_suggestions(years = 2011L, category = "Police", years_present = 2011L), + "govid") + expect_error( + cog_suggestions("061037123085", category = "Police", years_present = 2011L), + "years") + expect_error( + cog_suggestions("061037123085", years = 2011L, years_present = 2011L), + "category") + expect_error( + cog_suggestions("061037123085", years = 2011L, category = "Police"), + "years_present") +}) + +test_that("cog_suggestions() validates govid, years and category", { + expect_error( + cog_suggestions(character(0), 2011L, "Police", years_present = 2011L), + "govid") + expect_error( + cog_suggestions(NA_character_, 2011L, "Police", years_present = 2011L), + "govid") + expect_error( + cog_suggestions(list("061037123085"), 2011L, "Police", + years_present = 2011L), + "govid") + expect_error( + cog_suggestions("061037123085", integer(0), "Police", + years_present = 2011L), + "years") + expect_error( + cog_suggestions("061037123085", "2011", "Police", years_present = 2011L), + "years") + expect_error( + cog_suggestions("061037123085", 2011L, NULL, years_present = 2011L), + "category") + expect_error( + cog_suggestions("061037123085", 2011L, 1L, years_present = 2011L), + "category") + expect_error( + cog_suggestions("061037123085", 2011L, c("Police", NA), + years_present = 2011L), + "category") + expect_error( + cog_suggestions("061037123085", 2011L, c("All Categories", "Police"), + years_present = 2011L), + class = "uscogdata_all_categories_not_combinable") +}) + +test_that("cog_suggestions() validates flow and concept against each other", { + expect_error( + cog_suggestions("061037123085", 2011L, "Police", flow = "balances", + years_present = 2011L), + class = "uscogdata_invalid_flow") + expect_error( + cog_suggestions("061037123085", 2011L, "Police", flow = "spending", + concept = "general", years_present = 2011L), + class = "uscogdata_invalid_expenditure_concept") + expect_error( + cog_suggestions("061037123085", 2011L, "Taxes", flow = "revenue", + concept = "primary", years_present = 2011L), + class = "uscogdata_invalid_revenue_concept") + expect_error( + cog_suggestions("061037123085", 2011L, "Police", + concept = c("primary", "direct"), years_present = 2011L), + class = "uscogdata_invalid_expenditure_concept") +}) + +test_that("cog_suggestions() validates years_present", { + expect_error( + cog_suggestions("061037123085", 2011L, "Police", years_present = NULL), + "integer\\(0\\)") + expect_error( + cog_suggestions("061037123085", 2011L, "Police", years_present = "2011"), + "years_present") + expect_error( + cog_suggestions("061037123085", 2011L, "Police", + years_present = NA_integer_), + "years_present") +}) + +test_that("cog_suggestions() is exported", { + expect_true("cog_suggestions" %in% getNamespaceExports("uscogdata")) +})