R/utils.R

Defines functions score_mcq tbl_cols_sameclass apa_p apa_t ttest_identical safe_get_type safe_get_num matrix_vals_close matrix_same_dims form_rand_match form_fixed_match form_fixed_match_intern rectify_dimnames sort_ix_terms list_compiled setextra_mod setextra is_type lgl_vals_equal sget attempted lms_identical fun_result_identical lmerMods_identical lgl_vecs_equal chr_vecs_equal fun_exists fun_try objs_identical objs_identical get_err_string all_col_vals_unique safe_get_table tbl_has_cols tbl_cols_equal tbl_same_colnames tbl_same_dims tbl_same_nrows code_includes fn_regex tbls_identical vec_vals_close num_vals_close remove_comments def_by_code `%==%` round2 nlist_to_rmd

Documented in all_col_vals_unique apa_p apa_t attempted chr_vecs_equal code_includes def_by_code fn_regex form_fixed_match form_rand_match fun_exists fun_result_identical fun_try get_err_string is_type lgl_vals_equal lgl_vecs_equal list_compiled lmerMods_identical lms_identical matrix_same_dims matrix_vals_close nlist_to_rmd num_vals_close objs_identical remove_comments round2 safe_get_num safe_get_type score_mcq setextra setextra_mod sget tbl_cols_equal tbl_cols_sameclass tbl_has_cols tbl_same_colnames tbl_same_dims tbl_same_nrows tbls_identical ttest_identical vec_vals_close

#' Export named list as RMarkdown file
#'
#' @param nlist A named list, with the names corresponding to chunk names for the output file.
#' @param outfile Name of the output file.
#' @param overwrite Whether to overwrite existing file.
#' @return Path to output file, returned invisibly.
#' @export
nlist_to_rmd <- function(nlist, outfile = tempfile(), overwrite = FALSE) {
  if (!overwrite) {
    if (file.exists(outfile)) {
      stop("file '", outfile, "' exists and 'overwrite' = FALSE")
    }
  }
  cat("", file = outfile, append = FALSE) # create the file
  lines <- purrr::map(names(nlist), function(.nx) {
    cat("```{r ", .nx, "}\n", sep = "", file = outfile, append = TRUE)
    cat(nlist[[.nx]], sep = "\n", file = outfile, append = TRUE)
    cat("```\n\n", file = outfile, append = TRUE)
  })
  invisible(outfile)
}

#' Round up from .5
#'
#' @param x a numeric string (or number that can be converted to a
#'   string)
#' @param digits integer indicating the number of decimal places
#'   (`round`) or significant digits (`signif`) to be used.
#' @details Implements rounding using the "round up from .5" rule,
#'   which is more conventional than the "round to even" rule
#'   implemented by R's built-in \code{\link{round}} function. This
#'   implementation was taken from
#'   (https://stackoverflow.com/a/12688836).
#' @export
round2 = function(x, digits = 0) {
  posneg = sign(x)
  z = abs(x)*10^digits
  z = z + 0.5
  z = trunc(z)
  z = z/10^digits
  z*posneg
}

#' Vectorized version of all.equal
#'
#' @param x vector whose elements you want to compare
#' @param y vector whose elements you want to compare to x
#' @details performs element-by-element comparison, dealing properly
#'   with floating-point values
#' @export
`%==%` <- function(x, y) {
  mapply(function(x1, y1) {
    isTRUE(all.equal(x1, y1))
  }, x, y, USE.NAMES = FALSE)
}

#' Test whether a variable was defined statically or using code
#'
#' @param x name of the variable to check
#' @param ignore_case whether to ignore case of \code{x}
#' @param add whether to add feedback
#' @return logical; \code{TRUE} if the code defining the variable uses any function
#' @export
def_by_code <- function(x, code, ignore_case = FALSE, add = TRUE) {
  res <- FALSE
  ##browser()
  code2 <- code %>% remove_comments()
  code_head <- paste0("(^|.*;)\\s*(", x, "\\s*)(<-|=)")
  ix <- grep(code_head, code2,
             ignore.case = ignore_case)
  ##stop(paste(code2, collapse = "\n"))
  if (length(ix)) {
    code3 <- paste(code2[ix[length(ix)]:length(code2)], collapse = "\n")
    ## TODO: remove anything preceding
    code4 <- as.character(parse(text = sub(code_head, "\\2\\3", code3))[1])
    ## if (grepl("[A-Za-z]+\\s{0,1}\\(", code4)) {
    if (!grepl(paste0(code_head, "\\s*[0-9]"), code4)) {
      res <- TRUE
    } else {
      add_feedback("* write *code* to generate the solution; writing a numeric answer may become wrong if underlying data changes", add = add)
    }
  }
  res
}

#' Remove comments
#'
#' @param x code
#' @return code with comments removed
#' @export
remove_comments <- function(x) {
  res <- sub("#.+$", "", x)
  res[res != ""]
}



#' Are submission and solution numeric values close
#'
#' Test whether numeric values in the submission are close to corresponding values in the solution environment.
#'
#' @param subvar Name of the submission variable whose value you want to test
#' @param sol_env solution environment
#' @param solvar Name of the solution variable to test against
#' @param ignore.case whether to accept same variable name but different capitalization
#' @param tolerance how close the values have to be
#' @param add whether to add feedback
#' @return logical; \code{TRUE} if \code{abs(x - get(x, sol_env)) < tolerance}
#' @export
num_vals_close <- function(subvar, sol_env, solvar = subvar,
                           ignore.case = FALSE,
                           tolerance = .002, add = TRUE,
                           inherits = FALSE) {
  res <- c("is_single_val" = FALSE,
           "vals_match" = FALSE)

  sol_val <- get(solvar, envir = sol_env)
  obj <- subvar
  if (ignore.case) {
    obj <- grep(paste0("^", subvar, "$"),
                ls(parent.frame()), ignore.case = TRUE, value = TRUE)
    if (length(obj) != 1) {
      obj <- subvar # cancel
    }
  }
  if (!exists(obj, envir = parent.frame(), inherits = inherits)) {
    add_feedback(paste0("* you did not define `", subvar, "`"),
                 add = add)
  } else {
    sub_val <- safe_get_num(obj, env = parent.frame(),
                            inherits = inherits, add = FALSE)

    compare_vals <- TRUE
    if (inherits(sub_val, "data.frame")) {
      add_feedback(paste0("* `", subvar,
                          "` should be a single value, not a table"),
                   add = add)
      if (nrow(sub_val) == 1) {
        ## find the numeric columns and compare them all
        lgl_ix <- purrr::map_lgl(sub_val, ~ is.numeric(.x) | inherits(.x, "difftime"))
        if (sum(lgl_ix) > 0L) {
          vec <- as.vector(sub_val[1, lgl_ix])
          res["vals_match"] <- any((vec - sol_val) < tolerance)
          if (is.na(res["vals_match"])) {
            res["vals_match"] <- FALSE
            add_feedback("* `", subvar, "` had value `NA`")
          }
        }
      } else {
        add_feedback("* `", subvar, "` was a table containing multiple rows; should have been a vector or at least, a table with only one row", add = add)
      }
    } else if (is.vector(sub_val)) { ## it's a vector
      if (length(sub_val) == 1L) {
        res["is_single_val"] <- TRUE
      } else {
        add_feedback("* `", subvar,
                     "` was not a single value; comparing first element to solution")
        sub_val <- sub_val[1]
      }
      if (!is.numeric(sub_val)) {
        add_feedback("* `", subvar, "` was not numeric", add = add)
      } else {
        if (is.nan(sub_val) || is.infinite(sub_val) || is.na(sub_val)) {
          add_feedback("* `", subvar, "` was `NA`, `NaN`, `+Inf`, or `-Inf`",
                       add = add)
        } else {
          res["vals_match"] <- abs(sub_val - sol_val) < tolerance
          vv <- if (length(sol_val) > 1L) "values" else "value"
          if (res["vals_match"]) {
            add_feedback("* your ", vv, " for `", subvar, "` ",
                         "matched the solution", add = add)
          } else {
            add_feedback("* your ", vv, " for `", subvar, "` ",
                         "did not match the solution; see solution code",
                         add = add)
          }
        }
      }
    } else {
      add_feedback("* `", subvar, "` was not a vector", add = add)
    }
  }
  res
}

#' Are vector element values close?
#'
#' @param subvec name of the vector in the submission environment
#' @param sol_env the solution environment
#' @param tolerance how close the values have to be
#' @param add whether to add feedback
#' @return logical
#' @export
vec_vals_close <- function(subvec, sol_env, solvec = subvec,
                           tolerance = .002,
                           add = TRUE) {
  res <- c("lengths_match" = FALSE,
           "vals_match" = FALSE)

  sol_vec <- get(solvec, envir = sol_env)
  if (!exists(subvec, envir = parent.frame(), inherits = FALSE)) {
    add_feedback(paste0("* you did not define `", subvec, "`"),
                 add = add)
  } else {
    ## variable exists
    sub_vec <- get(subvec, envir = parent.frame(), inherits = FALSE)
    if (is.vector(sub_vec)) {
      if (length(sub_vec) == length(sol_vec)) {
        res["lengths_match"] <- TRUE
        if (!is.numeric(sub_vec)) {
          add_feedback("* `", subvec, "` was not numeric", add = add)
        } else {
          if (any(is.nan(sub_vec)) || any(is.infinite(sub_vec))) {
            add_feedback("* `", subvec,
                         "` contained `NaN`, `+Inf`, or `-Inf` values",
                         add = add)
          } else {
            ## if there aren't NAs in the solution, but there are in the
            ## submission, then it is wrong
            if (!any(is.na(sol_vec)) && any(is.na(sub_vec))) {
              add_feedback("* your answer contained NA values")
            } else { 
              if (length(sub_vec[!is.na(sub_vec)]) !=
                  length(sol_vec[!is.na(sol_vec)])) {
                add_feedback("* incorrect; see solution code")
              } else {
                res["vals_match"] <- all(abs(sub_vec - sol_vec) < tolerance,
                                         na.rm = TRUE) &&
                  (length(sub_vec) > 0L)
                if (res["vals_match"]) {
                  add_feedback("* correct", add = add)
                } else {
                  add_feedback("* incorrect; see solution code", add = add)
                }
              }
            }
          }
        }
      } else {
        add_feedback("* length of `", subvec,
                     "` did not match solution; had ", length(sub_vec),
                     " elements, but should have had ", length(sol_vec),
                     add = add)
      }
    } else {
      add_feedback("* `", subvec, "` was not a vector", add = add)
    }
  }
  res  
}

#' Are submission and solution tables identical
#'
#' @param subtbl Name of the table in submission environment.
#' @param sol_env The solution environment.
#' @param soltbl Name of the table in the solution environment.
#' @param ignore.case Whether to ignore case when matching submission and solution variable names.
#' @param roworder_strict Whether to require the submission table to have rows in the same order as the solution table.
#' @param colorder_strict Whether to require the submission table to have columns in the same order as the target table.
#' @param allow_extracols Whether to allow the submission table to have extra columns not present in the solution table.
#' @param add whether to add feedback
#' @return logical; returns result of \code{dplyr::setequal(tblname, get(tblname, sol_env))}
#' @export
tbls_identical <- function(subtbl, 
                           sol_env,
                           soltbl = subtbl,
                           ignore.case = FALSE,
                           roworder_strict = FALSE,
                           colorder_strict = FALSE,
                           allow_extracols = FALSE,
                           add = TRUE) {
  res <- FALSE
  sol_tbl <- get(soltbl, envir = sol_env)
  if (!is.null(sub_tbl <- safe_get_table(subtbl, parent.frame(), add))) {
    sol2 <- sol_tbl
    sub2 <- sub_tbl
    if (ignore.case) {
      ## only change if it won't produce an error
      if (dplyr::n_distinct(colnames(sub2)) == ncol(sub2)) {
        colnames(sol2) <- tolower(colnames(sol_tbl))
        colnames(sub2) <- tolower(colnames(sub_tbl))
      }
    }
    ## check for list columns, which will cause setequal to fail
    sub_types <- purrr::map_chr(sub2, class)
    sol_types <- purrr::map_chr(sol2, class)
    if (any(sub_types == "list")) {
      warning("tbls_identical is dropping list-column(s) named '",
              paste(colnames(sub2)[sub_types == "list"], collapse = ", "),
              "' from submission table '", subtbl, "'")
      sub2 <- sub2[sub_types != "list"]
    }
    if (any(sol_types == "list")) {
      warning("tbls_identical is dropping list-column(s) named '",
              paste(colnames(sol2)[sol_types == "list"], collapse = ", "),
              "' from solution table '", soltbl, "'")
      sol2 <- sol2[sol_types != "list"]      
    }
    if (allow_extracols) {
      ## reduce submission to include only cols in solution
      sub2 <- sub2[, intersect(colnames(sol2), colnames(sub2))]
    }
    if (!dplyr::setequal(sol2, sub2)) {
      add_feedback("* your table `", subtbl, "` differs from the solution table; see solution code", add = add)
    } else {
      ## strict on rows or columns?
      .testrow <- TRUE
      .testcol <- TRUE
      if (roworder_strict) {
        solrows <- sapply(sol2, order)
        subrows <- sapply(sub2, order)
        .testrow <- identical(subrows[, colnames(solrows)], solrows)
        if (!.testrow)
          add_feedback("* Rows of your table `", subtbl, "` were in a different order than the rows in the solution.", add = add)
      }
      if (colorder_strict) {
        .testcol <- identical(colnames(sol2), colnames(sub2))
        if (!.testcol)
          add_feedback("* Columns of your table `", subtbl, "` appeared in a different order than the columns in the solution.", add = add)
      }
      if (.testrow && .testcol) {
        add_feedback("* your table `", subtbl, "` matched the solution table",
                     add = add)
        
        res <- TRUE
      }
    }
  }
  res
}

#' Generate regular expression to search for R function call
#'
#' Generate a regular expression to use in a search function, to find a function call in R code.
#'
#' @param fname name of the function
#' @return a string containing a regular expression
#' @export
fn_regex <- function(fname) {
  paste0("(^|[^[:alnum:]_])*(", fname, ")[[:space:]]*\\(")
}

#' Test whether code includes a function
#'
#' Test whether the submission code includes function \code{fn}.
#'
#' @param fn function to search for
#' @param code the submission code (usually you pass the variable
#'   \code{._ar$current_code})
#' @param remove_comments do you want to remove comments before checking?
#' @return logical; \code{TRUE} if the function is found anywhere in
#'   the code (comments in the code are ignored).
#' @export
code_includes <- function(fn, code, remove_comments = TRUE) {
  any(grepl(fn_regex(fn),
            if (remove_comments) remove_comments(code) else code))
}

#' Same number of rows in table
#'
#' @param subtbl name of table in submission environment
#' @param sol_env solution environment
#' @param soltbl name of table in solution environment
#' @param add whether to add feedback
#' @return logical
#' @export
tbl_same_nrows <- function(subtbl, sol_env,
                           soltbl = subtbl,
                           add = TRUE) {
  res <- FALSE
  sol_tbl <- get(soltbl, envir = sol_env)
  if (!is.null(sub_tbl <- safe_get_table(subtbl, parent.frame(), add))) {
    if (length(dim(sub_tbl)) != length(dim(sol_tbl))) {
      add_feedback(paste0("* `", subtbl, "` was not a table"), add = add)
    } else {
      res <- nrow(sub_tbl) == nrow(sol_tbl)
      if (!res) {
        add_feedback("* `", subtbl, "` had ", nrow(sub_tbl),
                     " rows; should have had ", nrow(sol_tbl), " rows.",
                     add = add)
      }
    }
  }    
  res  
}

#' Are table dimensions the same
#'
#' @param subtbl name of table in submission environment
#' @param sol_env Solution environment
#' @param soltbl name of table in solution environment
#' @param add whether to add feedback
#' @return logical
#' @export
tbl_same_dims <- function(subtbl,
                          sol_env,
                          soltbl = subtbl, add = TRUE) {
  res <- FALSE
  sol_tbl <- get(soltbl, envir = sol_env)
  if (!is.null(sub_tbl <- safe_get_table(subtbl, parent.frame(), add))) {
    if (length(dim(sub_tbl)) != length(dim(sol_tbl))) {
      add_feedback(paste0("* `", subtbl, "` was not a table"), add = add)
    } else {
      res <- identical(dim(sub_tbl), dim(sol_tbl))
      if (!res) {
        add_feedback(paste0("* `", subtbl, "` should have been ",
                            dim(sol_tbl)[1], "x", dim(sol_tbl)[2],
                            "; yours was ",
                            dim(sub_tbl)[1], "x", dim(sub_tbl)[2]),
                     add = add)
      } else {
        add_feedback("* dimensions of your table matched the solution", add = add)
      }
    }
  }    
  res
}

#' Are table column names the same
#'
#' @param subtbl name of table in submission environment
#' @param sol_env Solution environment
#' @param soltbl name of table in solution environment
#' @param add whether to add feedback
#' @return logical
#' @export
tbl_same_colnames <- function(subtbl,
                           sol_env,
                           soltbl = subtbl, add = TRUE) {
  res <- FALSE
  sol_tbl <- get(soltbl, envir = sol_env)
  if (!is.null(sub_tbl <- safe_get_table(subtbl, parent.frame(), add))) {
    if (!(res <- setequal(colnames(sub_tbl), colnames(sol_tbl)))) {
      add_feedback(paste0("* `", subtbl, "` did not have the same column names as solution table"), add = add)
    }
  }
  res
}

#' Are table column values equal
#'
#' @param subtbl name of submission table
#' @param subcol name of column in submission table
#' @param sol_env solution environment
#' @param soltbl name of table of solution environment
#' @param solcol name of column in solution table
#' @param ignore_order should the ordering of the values be ignored?
#' @param add whether to add feedback
#' @details uses \code{\link{all.equal}} to deal with floating point values
#' @return logical
#' @export
tbl_cols_equal <- function(subtbl, subcol,
                           sol_env,
                           soltbl = subtbl,
                           solcol = subcol,
                           ignore_order = TRUE,
                           add = TRUE) {
  ##browser()
  res <- FALSE
  sol_tbl <- get(soltbl, envir = sol_env)
  sub_tbl <- safe_get_table(subtbl, parent.frame(), add)
  if (!is.null(sub_tbl)) {
    if (!(subcol %in% colnames(sub_tbl))) {
      add_feedback("* column name `", subcol, "` was missing from your table `",
                   subtbl, "`", add = add)
    } else {
      if (ignore_order) {
        res <- isTRUE(all.equal(sort(sub_tbl[[subcol]]),
                                sort(sol_tbl[[solcol]])))
      } else {
        res <- isTRUE(all.equal(sub_tbl[[subcol]],
                                sol_tbl[[solcol]]))
      }
      if (!res) {
        add_feedback("* values or data type for column `", subcol,
                     "` in `", subtbl, "` were incorrect",
                     add = add)
      } else {
        add_feedback("* values in column `", subcol, "` of `", subtbl,
                     "` matched corresponding values in solution table",
                     add = add)
      }
    }
  }
  res
}

#' Does a submission table have required columns?
#'
#' @param subtbl name of table in submission environment
#' @param subcols names of columns that should exist
#' @param add whether to give feedback
#' @return logical
#' @export
tbl_has_cols <- function(subtbl, subcols, add = TRUE) {
  res <- FALSE
  if (!is.null(sub_tbl <- safe_get_table(subtbl, parent.frame(), add))) {
    nvec <- setdiff(subcols, colnames(sub_tbl))
    res <- length(nvec) == 0L
    if (!res) {
      add_feedback("* your table `", subtbl,
                   "` was missing the following column(s): ",
                   paste0("`", paste(nvec, collapse = "`, `"), "`"),
                   add = add)
    }
  }
  res
}

#' @export
safe_get_table <- function(tblname, env, add = TRUE) {
  res <- NULL
  if (!exists(tblname, envir = env, inherits = FALSE)) {
    add_feedback(paste0("* you did not define `", tblname, "` (your code failed because of an error, or you renamed variables given to you)"),
                        add = add)
  } else {
    res <- get(tblname, envir = env, inherits = FALSE)
    if (!inherits(res, "data.frame")) {
      add_feedback(paste0("* `", tblname, "` was not a table"), add = add)
      res <- NULL
    }
  }
  res
}

#' Are all column values unique?
#'
#' @param subtbl name of submission table
#' @param subcol name of submission column
#' @return logical
all_col_vals_unique <- function(subtbl, subcol, add = TRUE) {
  res <- FALSE
  d <- safe_get_table(subtbl, parent.frame(), add)
  if (!is.null(d)) {
    res <- length(unique(d[[subcol]])) == length(d[[subcol]])
  } else {
    add_feedback("* all values in column `", subcol, "` should be unique", add = add)
  }
  res
}

#' Get a printable string from error message
#'
#' @param x a condition
#' @return string with the error message
#' @export
get_err_string <- function(x) {
  f <- tempfile()
  msg <- conditionMessage(x)
  call <- conditionCall(x)
  cl <- class(x)[1L]
  if (!is.null(call)) 
    cat("<", cl, " in ", deparse(call), ": ", msg, ">\n", 
        sep = "", file = f)
  else cat("<", cl, ": ", msg, ">\n", sep = "", file = f)
  paste(readLines(f), collapse = "\n")
}

#' Are objects the same
#'
#' @param subobj name of submission object
#' @param sol_env solution environment
#' @param solobj name of solution object
#' @param add add feedback
#' @param all_equal whether to use \code{all_equal} instead of \code{identical}
#' @return logical
#' @details \code{objs_identical} uses \code{identical}; \code{objs_all_equal} usesuse this to compare any two objects (e.g., fitted model objects resulting from a call to `lm()`, `aov()`, etc)
#' @export
objs_identical <- function(subobj,
                           sol_env,
                           solobj = subobj,
                           add = TRUE,
                           all_equal = FALSE) {
  
  res <- FALSE
  sol_obj <- get(solobj, envir = sol_env)
  if (!exists(subobj, envir = parent.frame(), inherits = FALSE)) {
    add_feedback("* object `", subobj,
                 "` was not defined; check spelling/capitalization",
                 add = add)
  } else {
    sub_obj <- get(subobj, envir = parent.frame(), inherits = FALSE)
    if (!identical(class(sub_obj), class(sol_obj))) {
      add_feedback("* object `", subobj, "` was of incorrect type; was of class `",
                   paste(class(sub_obj), collapse = ", "), "` but should have been `",
                   paste(class(sol_obj), collapse = ", "), "`", add = add)
    } else {
      if (!is.null(sub_obj$model)) {
        attributes(sub_obj$model) <- NULL
        attributes(sol_obj$model) <- NULL
        attributes(sub_obj$terms) <- NULL
        attributes(sol_obj$terms) <- NULL
      }
      if (!all_equal) {
        res <- identical(sub_obj, sol_obj, ignore.environment = TRUE)
      } else {
        res <- isTRUE(all.equal(sub_obj, sol_obj, check.attributes = FALSE))
      }
      if (res) {
        add_feedback("* `", subobj, "` matched solution", add = add)
      } else {
        add_feedback("* `", subobj, "` did not match solution", add = add)
      }
    }
  }
  res
}


#' Are objects equal
#'
#' @describeIn objs_identical
#'
#' @export
objs_identical <- function(subobj,
                           sol_env,
                           solobj = subobj,
                           add = TRUE) {
  res <- FALSE
  sol_obj <- get(solobj, envir = sol_env)
  if (!exists(subobj, envir = parent.frame(), inherits = FALSE)) {
    add_feedback("* object `", subobj,
                 "` was not defined; check spelling/capitalization",
                 add = add)
  } else {
    sub_obj <- get(subobj, envir = parent.frame(), inherits = FALSE)
    if (!identical(class(sub_obj), class(sol_obj))) {
      add_feedback("* object `", subobj, "` was of incorrect type; was of class `",
                   paste(class(sub_obj), collapse = ", "), "` but should have been `",
                   paste(class(sol_obj), collapse = ", "), "`", add = add)
    } else {
      if (!is.null(sub_obj$model)) {
        attributes(sub_obj$model) <- NULL
        attributes(sol_obj$model) <- NULL
        attributes(sub_obj$terms) <- NULL
        attributes(sol_obj$terms) <- NULL
      }
      res <- identical(sub_obj, sol_obj, ignore.environment = TRUE)
      if (res) {
        add_feedback("* `", subobj, "` matched solution", add = add)
      } else {
        add_feedback("* `", subobj, "` did not match solution", add = add)
      }
    }
  }
  res
}


#' Safely try out a function defined in the submission
#'
#' @param fnname name of the function
#' @param add whether to add feedback
#' @param ... arguments to fnname
#' @return list with two values: \code{is_error} (logical) and \code{result}
fun_try <- function(fnname, ..., add = TRUE) {
  res <- list(error = TRUE, result = NULL)
  if (!exists(fnname, envir = parent.frame(), inherits = FALSE)) {
    add_feedback("* you didn't define the function `", fnname,"` (check capitalization and spelling)")
  } else {
    fres <- tryCatch(do.call(fnname, list(...)), error = function(e) e)
    if (evaluate::is.error(fres)) {
      res$error <- TRUE
      res$result <- NULL
      add_feedback("* could not run your function `", fnname, "`; it threw an error", add = TRUE)
    } else {
      res$error <- FALSE
      res$result <- fres
    }
  }
  res
}


#' Does function exist in submission environment?
#'
#' @param fnname name of function
#' @param add add feedback? (logical)
#' @return logical
fun_exists <- function(fnname, add = TRUE) {
  res <- FALSE
  if (!exists(fnname, envir = parent.frame(), inherits = FALSE)) {
    add_feedback("* `", fnname, "` does not exist (check spelling and capitalization)", add = add)
  } else {
    ff <- get(fnname, envir = parent.frame(), inherits = FALSE)
    if (!is.function(ff)) {
      add_feedback("* `", fnname, "` is not a function", add = add)
    } else {
      res = TRUE
    }
  }
  res
}

#' Are character vectors equal?
#'
#' @param subvar name of variable in submission environment
#' @param sol_env solution environment
#' @param solvar name of solution variable
#' @param ignore_order whether to ignore the ordering
#' @param ignore_case whether to ignore the case
#' @param add whether to add feedback
#' @return logical
#' @export
chr_vecs_equal <- function(subvar, sol_env, solvar = subvar,
                           ignore_order = FALSE,
                           ignore_case = FALSE,
                           add = TRUE) {
  res <- c("lengths_match" = FALSE, "vals_match" = FALSE, "case_match" = FALSE)
  if (!exists(subvar, parent.frame(), inherits = FALSE)) {
    add_feedback("* you did not define `", subvar,
                 "` (check spelling and capitalization)", add = add)
  } else {
    sub_var <- get(subvar, parent.frame(), inherits = FALSE)
    sol_var <- get(solvar, sol_env)
    if (!is.vector(sub_var)) {
      add_feedback("* `", subvar, "` was not a vector", add = add)
    } else {
      if (length(sub_var) != length(sol_var)) {
        add_feedback("* length of `", subvar, "` (", length(sub_var),
                     ") did not match solution (", length(sol_var), ")",
                     add = add)
      } else {
        res["lengths_match"] <- TRUE
        if (mode(sub_var) != "character") {
          add_feedback("* `", subvar, "` was not of type 'character'")
        } else {
          if (ignore_order) {
            res["vals_match"] <- setequal(tolower(sub_var), tolower(sol_var))
            res["case_match"] <- setequal(sub_var, sol_var)
          } else {
            res["vals_match"] <- identical(tolower(sub_var), tolower(sol_var))
            res["case_match"] <- identical(sub_var, sol_var)
          }
          if (res["vals_match"]) {
            if (ignore_case) {
              res <- res[1:2] # get ride of case_match
              add_feedback("* correct", add = add)
            } else if (res["case_match"]) {
              add_feedback("* correct", add = add)
            } else {
              add_feedback("* the case does not match (check uppercase versus lowercase characters)", add = add)
            }
          } else {
            add_feedback("* incorrect; see solution code", add = add)
          }
        }
      }
    }
  }
  res
}


#' Are logical vectors equal?
#'
#' @param subvar name of variable in submission environment
#' @param sol_env solution environment
#' @param solvar name of solution variable
#' @param add whether to add feedback
#' @return logical
#' @export
lgl_vecs_equal <- function(subvar, sol_env, solvar = subvar,
                           add = TRUE) {
  res <- c("lengths_match" = FALSE, "vals_match" = FALSE)
  if (!exists(subvar, parent.frame(), inherits = FALSE)) {
    add_feedback("* you did not define `", subvar,
                 "` (check spelling and capitalization)", add = add)
  } else {
    sub_var <- get(subvar, parent.frame(), inherits = FALSE)
    sol_var <- get(solvar, sol_env)
    if (!is.vector(sub_var)) {
      add_feedback("* `", subvar, "` was not a vector", add = add)
    } else {
      if (length(sub_var) != length(sol_var)) {
        add_feedback("* length of `", subvar, "` (", length(sub_var),
                     ") did not match solution (", length(sol_var), ")",
                     add = add)
      } else {
        res["lengths_match"] <- TRUE
        if (mode(sub_var) != "logical") {
          add_feedback("* `", subvar, "` was not of type 'logical'", add = add)
        } else {
          res["vals_match"] <- all(sub_var == sol_var)
          if (res["vals_match"]) {
            add_feedback("* `", subvar, "` matched the solution", add = add)
          } else {
            add_feedback("* `", subvar, "` did not match the solution", add = add)
          }
        }
      }
    }
  }
  res
}

#' Are lmer models identical?
#'
#' @param subvar name of submission variable
#' @param solenv solution environment
#' @param solvar name of solution variable
#' @param add whether to add feedback
#' @return logical
#' @export
lmerMods_identical <- function(subvar, solenv, solvar = subvar, add = TRUE) {
  res <- FALSE
  if (!exists(subvar, parent.frame(), inherits = FALSE)) {
    add_feedback("* `", subvar, "` was not defined", add = add)
  } else {
    submod <- get(subvar, parent.frame(), inherits = FALSE)
    if (!inherits(submod, "lmerMod")) {
      add_feedback("* `", subvar, "` was not of type `lmerMod`", add = add)
    } else {
      solmod <- get(solvar, solenv, inherits = FALSE)
      res <- all.equal(VarCorr(solmod), VarCorr(submod)) &&
        all.equal(attr(VarCorr(solmod), "sc"), attr(VarCorr(submod), "sc")) &&
        all.equal(fixef(solmod), fixef(submod))
      if (res) {
        add_feedback("* model `", subvar, "` matched solution", add = add)
      } else {
        add_feedback("* model `", subvar, "` didn't match solution", add = add)
      }
    }
  }
  res
}

#' Do return values from a function match solution?
#'
#' @param subfn name of function in submission environment
#' @param args list of args to pass to function
#' @param solenv solution environment
#' @param solfn name of solution function
#' @param sameseed whether to run the two functions with the same state of \code{.Random.seed}
#' @param add whether to add feedback
#' @details compare the result of the function in the submission environment to its result in the solution environment
#' @return logical
#' @export
fun_result_identical <- function(subfn, args = list(),
                                 solenv, solfn = subfn, sameseed = TRUE,
                                 add = TRUE) {
  result <- FALSE
  if (!exists(subfn, parent.frame(), inherits = FALSE)) {
    add_feedback("* you didn't create a function named `", subfn,
                 "` (check capitalization and spelling)", add = add)
  } else if (!is.function(get(subfn, parent.frame(), inherits = FALSE))) {
    add_feedback("* variable `", subfn, "` was not a function", add = add)
  } else {
    curseed <- get(".Random.seed", envir = globalenv())
    ## eval(set.seed(999), envir = globalenv())
    res <- try(do.call(subfn, args, envir = parent.frame()), silent = TRUE)
    if (inherits(res, "try-error")) {
      add_feedback("* your function threw an error during evaluation", add = add)
    } else {
      ## reset the seed
      if (sameseed) {
       ## assign(".Random.seed", curseed, envir = parent.frame())
       assign(".Random.seed", curseed, envir = globalenv())
      }
      ## eval(set.seed(999), envir = globalenv())
      ##eval(set.seed(999), envir = solenv)
      res2 <- do.call(solfn, args, envir = solenv)
      fres <- all.equal(res, res2)
      result <- isTRUE(fres)
      if (result) {
        add_feedback("* your function worked as expected", add = add)
      } else {
        add_feedback("* your function did not work as expected", add = add)
      }
    }
  }

  result
}

#' Are linear models identical?
#'
#' @param subvar name of variable in submission environment
#' @param solenv solution environment
#' @param solvar name of variable in solution environment
#' @param add whether to add feedback
#' @return logical value
#' @export
lms_identical <- function(subvar, solenv, solvar = subvar, add = TRUE) {
  res <- FALSE
  sol_obj <- get(solvar, envir = solenv)
  if (!exists(subvar, envir = parent.frame(), inherits = FALSE)) {
    add_feedback("* variable `", subvar,
                 "` was not defined; check spelling/capitalization",
                 add = add)
  } else {
    sub_obj <- get(subvar, envir = parent.frame(), inherits = FALSE)
    if (!identical(class(sub_obj), class(sol_obj))) {
      add_feedback("* object `", subvar, "` was of incorrect type; was of class `",
                   paste(class(sub_obj), collapse = ", "), "` but should have been `",
                   paste(class(sol_obj), collapse = ", "), "`", add = add)
    } else {
      res <- dplyr::setequal(broom::tidy(sub_obj), broom::tidy(sol_obj))
      if (res) {
        add_feedback("* `", subvar, "` matched solution", add = add)
      } else {
        add_feedback("* `", subvar, "` did not match solution", add = add)
      }
    }
  }
  res
}

#' Check whether any attempt was made to answer the question
#'
#' @param subvar Name of the submission variable to test.
#' @details If no attempt was made, then the value of \code{subvar} will remain \code{NULL}.
#' @return Returns \code{FALSE} only if \code{subvar} is \code{NULL}.
#' @export
attempted <- function(subvar, add = TRUE, inherits = FALSE) {
  res <- TRUE
  if (exists(subvar, envir = parent.frame(), inherits = inherits)) {
    sub_var <- get(subvar, envir = parent.frame(), inherits = inherits)
    res <- !is.null(sub_var)
    if (!res) {
      add_feedback("* No attempt", add = add)
    }
  } else {
    res <- FALSE
    add_feedback("* variable `", subvar, "` not defined", add = add)
  }
  res
}


#' Safely return object
#'
#' @param object name of variable
#' @param classes type of object
#' @return a list with elements \code{obj}, the object (if it exists, NULL otherwise), \code{exists} (TRUE if it does, FALSE if not), \code{type} (TRUE if object is of type 'class', otherwise FALSE), and \code{message}, a character vector describing any errors (or NULL on success)
#' @export
sget <- function(.obj, .class, env) {
  .retobj <- NULL
  .exists <- FALSE
  .type <- NA
  .message <- c("")
  if (exists(.obj, envir = env, inherits = FALSE)) {
    .retobj <- get(.obj, envir = env, inherits = FALSE)
    .exists <- TRUE
    if (inherits(.retobj, .class)) {
      .type <- TRUE
    } else {
      .retobj <- NULL
      .type <- FALSE
      .message <- paste0("* object `", .obj, "` was not of type '",
                         .class, "'")
    }
  } else {
    .message <- paste0("* object `", .obj, "` not found")
  }
  list(obj = .retobj, exists = .exists, type = .type,
       message = as.character(.message))
}

#' Are logical values the same?
#'
#' @param subvar name of variable in submission environment
#' @param sol_env solution environment
#' @param solvar name of solution variable
#' @param add whether to add feedback
#' @return logical
#' @export
lgl_vals_equal <- function(subvar, sol_env, solvar = subvar, add = TRUE) {
  result <- FALSE
  ## make sure solution variable is length 1 vector
  sol_lgl <- sget(solvar, "logical", sol_env)
  if (is.null(sol_lgl))
    stop("solution variable `", solvar, "` was not of type 'logical'")
  if (length(sol_lgl$obj) != 1L)
    stop("solution variable `", solvar, "` was not of length 1")
  sub_lgl <- sget(subvar, "logical", parent.frame())
  if (is.null(sub_lgl$obj)) {
    add_feedback(sub_lgl$message, add = add)
  } else {
    if (sol_lgl$obj == sub_lgl$obj) {
      result <- TRUE
      add_feedback("variable `", subvar, "` matched the solution", add = add)
    } else {
      add_feedback("variable `", subvar, "` did not match the solution",
                   add = add)
    }
  }
  result
}

#' Safely determine variable type
#'
#' @param subvar Name of submission variable
#' @param class Class of submission variable
#' @return logical: TRUE if 'subvar' is of type 'class'
#' @export
is_type <- function(subvar, class) {
  res <- sget(subvar, class, parent.frame())
  if (is.na(res$type)) FALSE else res$type
}


#' Test set difference
#'
#' Does the difference in elements between first set x and second set
#' y correspond to vector v?
#' 
#' @param x First set
#' @param y Second set
#' @param v Difference vector
#' @return logical
#' @export
setextra <- function(x, y, v) {
  res <- base::setdiff(x, y)
  if (length(res) == 0) {
    FALSE
  } else {
    base::setequal(res, v)
  }
}

#' Test difference in model (fixed effects) terms
#'
#' @param First model
#' @param Second model
#' @param Difference vector
#' @return logical
#' @details See whether two models differ in fixed effects terms. This can be tricky because interaction terms that are ordered differently (\code{x:y} and \code{y:x}) should be treated as identical. Any interactions will always be returned sorted in alphabetical order.
#' @export
setextra_mod <- function(x, y, v) {
  recomb <- function(.x) paste(sort(.x), collapse = ":")

  lx <- strsplit(labels(terms(x)), ":")
  ly <- strsplit(labels(terms(y)), ":")

  omitted_in_ly <- !sapply(lx, function(.x) {
    any(sapply(ly, base::setequal, .x))
  })

  lv <- strsplit(v, ":")
  lv2 <- sapply(lv, recomb)
  omt <- sapply(lx[omitted_in_ly], recomb)

  base::setequal(lv2, omt)
}

#' List all compiled html files
#'
#' List all html files compiled from RMarkdown
#'
#' @param subdir subdirectory in which to scan for html files
#' @param with_moodle_id extract the moodle identifier
#' @return if \code{with_moodle_id} is \code{FALSE}, returns a vector
#'   with paths to submission files; if \code{TRUE}, a table with
#'   submission id and filename.
#' @export
list_compiled <- function(subdir,
                          with_moodle_id = FALSE) {
  files <- list.files(sub("/$", "", subdir),
                      "\\.html$", full.names = TRUE,
                      recursive = TRUE)
  if (with_moodle_id) {
    tibble::tibble(sub_id = moodle_id(files),
                   filename = files)
  } else {
    files
  }
}

## sort interaction terms
sort_ix_terms <- function(x) {
  sapply(lapply(strsplit(x, ":"), sort), paste, collapse = ":")
}

rectify_dimnames <- function(mx) {
  names(dimnames(mx)) <- NULL
  dimnames(mx) <- lapply(dimnames(mx), as.character)
  dimnames(mx)
}

## turn lme4:::findbars() into a formula
form_fixed_match_intern <- function(subf, solf, subvar = NULL, add = FALSE) {
  res <- FALSE
  subff <- lme4:::nobars(subf)
  solff <- lme4:::nobars(solf)

  ## do RHS terms match?
  labsub <- attr(terms(subff), "term.labels")
  names(labsub) <- sort_ix_terms(labsub)
  labsub <- sort(labsub)
  labsol <- attr(terms(solff), "term.labels")
  names(labsol) <- sort_ix_terms(labsol)
  labsol <- sort(labsol)
  tmatch <- setequal(names(labsub), names(labsol))
  if (!tmatch) {
    add_feedback("* `", subvar, "` had different fixed effects terms than solution", add = add)
  } else {
    submx <- attr(terms(subff), "factors")
    solmx <- attr(terms(solff), "factors")
    if ((length(submx) == 0) || length(solmx) == 0) { # intercept only
      if (length(submx) == length(solmx)) {
        res <- TRUE
      } else {
        add_feedback("* `", subvar, "` had different fixed effects terms than solution", add = add)
      }
    } else {
      colnames(submx) <- sort_ix_terms(colnames(submx))
      colnames(solmx) <- sort_ix_terms(colnames(solmx))
      dnmatch <- all(mapply(setequal, dimnames(submx), dimnames(solmx)))
      if (dnmatch) {
        dimnames(submx) <- rectify_dimnames(submx)
        dimnames(solmx) <- rectify_dimnames(solmx)
        mxmatch <- isTRUE(all.equal(submx, solmx[dimnames(submx)[[1]],
                                                 dimnames(submx)[[2]],
                                                 drop = FALSE]))
      }
      if (!(dnmatch && mxmatch)) {
        add_feedback("* `", subvar, "` had different fixed effects structure than solution", add = add)
      } else {
        res <- TRUE
      }
    }
  }
  res
}

#' Do fixed terms in formula match?
#'
#' @param subvar Name of submission variable containing formula.
#' @param solenv Name of solution environment.
#' @param solvar Name of solution variable.
#' @param add Whether to add feedback.
#' @details Can handle regular linear model formulae (\code{Y ~ X}) as well as mixed effects models (\code{Y ~ X + (X | subject)}).
#' @return logical
#' @export
form_fixed_match <- function(subvar, solenv, solvar = subvar, 
                             add = TRUE) {
  res <- FALSE
  if (inherits(subvar, "formula")) {
    add_feedback("* `", subvar, "` was not of type 'formula'", add = add)
  } else {
    subf <- get(subvar, envir = parent.frame(), inherits = FALSE)
    solf <- get(solvar, envir = solenv)
    res <- form_fixed_match_intern(subf, solf, subvar, add)
  }
  if (res) {
    add_feedback("* fixed effects part was correct", add = add)
  }
  res
}

#' Do random terms in formula match?
#'
#' @param subvar Name of submission variable containing formula.
#' @param solenv Name of solution environment.
#' @param solvar Name of solution variable.
#' @param add Whether to add feedback.
#' @details Can handle regular linear model formulae (\code{Y ~ X}) as well as mixed effects models (\code{Y ~ X + (X | subject)}).
#' @return logical
#' @export
form_rand_match <- function(subvar, solenv, solvar = subvar, 
                             add = TRUE) {
  res <- FALSE
  if (inherits(subvar, "formula")) {
    add_feedback("* `", subvar, "` was not of type 'formula'", add = add)
  } else {
    subf <- get(subvar, envir = parent.frame(), inherits = FALSE)
    solf <- get(solvar, envir = solenv)
    if (!lme4:::anyBars(solf)) {
      stop("formula did not have random effects")
    }
    if (!lme4:::anyBars(subf)) {
      add_feedback("* `", subvar, "` did not have any random effects terms",
                   add = add)
    } else {
      ## got here; there are terms. check them
      subb <- sort(sapply(lme4:::findbars(subf), deparse))
      solb <- sort(sapply(lme4:::findbars(solf), deparse))
      if (length(subb) == length(solb)) {
        ## same length; parse out the bars
        sub_unit <- stringr::str_trim(sapply(strsplit(subb, "\\|+"), `[[`, 2L))
        sol_unit <- stringr::str_trim(sapply(strsplit(solb, "\\|+"), `[[`, 2L))
        if (any(duplicated(sub_unit))) {
          add_feedback("* same sampling unit appears more than once in random effects term", add = add)
        } else {
          if (!setequal(sub_unit, sol_unit) || (length(sub_unit) != length(sol_unit))) {
            add_feedback("* different sampling units in random effects terms from solution",
                         add = add)
          } else {
            sub_fstr <- paste0("DV ~ ", sapply(strsplit(subb, "\\|+"), `[[`, 1L))
            names(sub_fstr) <- sub_unit
            sol_fstr <- paste0("DV ~ ", sapply(strsplit(solb, "\\|+"), `[[`, 1L))
            names(sol_fstr) <- sol_unit
            sub_fstr2 <- sapply(sub_fstr, as.formula)
            sol_fstr2 <- sapply(sol_fstr, as.formula)
            res <- all(mapply(form_fixed_match_intern,
                              sub_fstr2, sol_fstr2[names(sub_fstr2)]))
            if (!res)
              add_feedback("* random effects structure did not match solution",
                           add = add)
          }
        }
      } else {
        add_feedback("* different number of random effect terms than solution",
                     add = add)
      }
    }
  }
  if (res)
    add_feedback("* random effects terms were correct", add = add)
  res
}

#' Do matrices have same dimensions?
#'
#' @param submx Quoted name of submission matrix.
#' @param sol_env Solution environment.
#' @param solmx Name of solution matrix.
#' @param add Whether to add feedback.
#' @return Logical corresponding to whether matrices have same dimensions.
#' @export
matrix_same_dims <- function(submx,
                             sol_env,
                             solmx = submx, add = TRUE) {
  res <- FALSE
  sol_mx <- get(solmx, envir = sol_env)
  sub_mx <- safe_get_type(submx, "matrix", parent.frame(), add = add)

  ## now compare
  if (!is.null(sub_mx)) {
    if (length(dim(sub_mx)) != length(dim(sol_mx))) {
      add_feedback(paste0("* `", submx, "` had the wrong number of dimensions (",
                          length(dim(sub_mx)), "); should have had ",
                          length(dim(sol_mx))), add = add)
    } else {
      res <- identical(dim(sub_mx), dim(sol_mx))
      if (!res) {
        add_feedback(paste0("* `", submx, "` should have been ",
                            dim(sol_mx)[1], "x", dim(sol_mx)[2],
                            "; yours was ",
                            dim(sub_mx)[1], "x", dim(sub_mx)[2]),
                     add = add)
      } else {
        add_feedback("* dimensions of your table matched the solution", add = add)
      }
    }
  }    
  res
}

#' Are the matrix values close?
#'
#' @param submx Quoted name of submission matrix.
#' @param sol_env Solution environment.
#' @param solmx Name of solution matrix.
#' @param tolerance How close the values need to be to be considered identical.
#' @param add Whether to add feedback.
#' @details Note that the dimensions are not checked, so can return TRUE if the values are close but come from a matrix of a different dimension.
#' @seealso \code{\link{matrix_same_dims}}
#' @return Number of cell values matching the solution.
#' @export
matrix_vals_close <- function(submx,
                              sol_env,
                              solmx = submx,
                              tolerance = .002,
                              add = TRUE) {
  sol_mx <- get(solmx, envir = sol_env)
  result <- rep(FALSE, prod(dim(sol_mx)))
  names(result) <- paste0("cell_", seq_len(prod(dim(sol_mx))))

  sub_mx <- safe_get_type(submx, "matrix", parent.frame(), add = add)

  if (!is.null(sub_mx)) {
    sol_vec <- c(sol_mx)
    sub_vec <- c(sub_mx)
    if (length(sol_vec) != length(sub_vec)) {
      add_feedback("* submission matrix had different number of values (",
                   length(sub_vec), ") from solution (",
                   length(sol_vec), ")")
    } else {
      result[] <- dplyr::near(sol_vec, sub_vec, tolerance)
      if (all(result)) {
        add_feedback("* all your matrix values matched the solution", add = add)
      } else {
        add_feedback("* ", sum(result), " of ", length(sol_vec),
                     " values in your matrix matched the solution", add = add)
      }
    }
  }
  result
}

#' Safely get a double or an integer
#' 
#' @param x Quoted name of the variable.
#' @param env The environment in which to search.
#' @param inherits Whether to search in the parent environments.
#' @param add Whether or not to add feedback.
#' @details First checks whether the variable exists in the environment. If it does, then checks whether it is of the appropriate type.
#' @return A value of the desired type if found, or \code{NULL} if not found.
#' @export
safe_get_num <- function(x, env = parent.frame(), inherits = FALSE,
                         add = TRUE) {
  ## attempt to retrieve from submission environment
  res <- NULL
  if (!exists(x, env, inherits = inherits)) {
    add_feedback(paste0("* you did not define `", x, "` (your code failed because of an error, or you renamed variables given to you)"),
                 add = add)
  } else {
    res <- get(x, envir = env, inherits = inherits)
    if (!(inherits(res, "numeric") || inherits(res, "integer"))) {
      add_feedback(paste0("* `", x, "` was not a number"),
                   add = add)
      res <- NULL
    }
  }
  res
}

#' Safely get a variable of specific type.
#'
#' @param x Quoted name of the variable.
#' @param type The data type of the variable.
#' @param env The environment in which to search.
#' @param inherits Whether to search in the parent environments.
#' @param add Whether or not to add feedback.
#' @details First checks whether the variable exists in the environment. If it does, then checks whether it is of the appropriate type.
#' @return A value of the desired type if found, or \code{NULL} if not found.
#' @export
safe_get_type <- function(x, type, env = parent.frame(), inherits = FALSE,
                          add = TRUE) {
  ## attempt to retrieve matrix from submission environment
  res <- NULL
  if (!exists(x, env, inherits = inherits)) {
    add_feedback(paste0("* you did not define `", x, "` (your code failed because of an error, or you renamed variables given to you)"),
                 add = add)
  } else {
    res <- get(x, envir = env, inherits = inherits)
    if (!inherits(res, type)) {
      add_feedback(paste0("* `", x, "` was not of type '", type, "'"),
                   add = add)
      res <- NULL
    }
  }
  res
}

#' Check whether t-test objects are identical
#'
#' @param subvar Quoted name of the variable.
#' @param sol_env Solution environment.
#' @param solvar Quoted name of variable in submission environment.
#' @param tolerance Three-element numeric vector, how close values have to be.
#' @param add Whether to add feedback.
#' @return A logical vector with elements mmatch, tmatch, dfmatch, pmatch.
#' @export
ttest_identical <- function(subvar, sol_env,
                            solvar = subvar,
                            tolerance = c(.2, .02, .2, .002), # mean, t, df, p-val
                            add = TRUE) {
  res <- c(mmatch = FALSE, tmatch = FALSE, dfmatch = FALSE, pmatch = FALSE)
  sol_t <- get(solvar, sol_env)
  sub_t <- safe_get_type(subvar, "htest", parent.frame(), add = add)

  if (!is.null(sub_t)) {
    subtbl <- broom::tidy(sub_t)
    soltbl <- broom::tidy(sol_t)

    res["mmatch"] <-
      (dplyr::near(subtbl$estimate1, soltbl$estimate1, tolerance[1]) &&
       dplyr::near(subtbl$estimate2, soltbl$estimate2, tolerance[1])) ||
      (dplyr::near(subtbl$estimate1, soltbl$estimate2, tolerance[1]) &&
       dplyr::near(subtbl$estimate2, soltbl$estimate1, tolerance[1]))
    if (is.na(res["mmatch"])) ## one-sample test
      res["mmatch"] <- FALSE
    res["tmatch"] <- dplyr::near(abs(subtbl$statistic),
                                 abs(soltbl$statistic), tolerance[2])
    res["dfmatch"] <- dplyr::near(subtbl$parameter,
                                  soltbl$parameter, tolerance[3])
    res["pmatch"] <- dplyr::near(subtbl$p.value,
                                 soltbl$p.value, tolerance[4])
    add_feedback("* solution t-test: ", apa_t(sol_t), add = add)
    add_feedback("* your t-test: ", apa_t(sub_t), add = add)
    if (all(res)) {
      add_feedback("* matched solution", add = add)
    } else {
      add_feedback("* did not match solution", add = add)
    }
  }
  res
}

#' Report a t-test in APA format
#'
#' @param x A t-test object, result of the call to \code{t.test}.
#' @return A string displaying results in APA format.
#' @export
apa_t <- function(x) {
  t_tbl <- broom::tidy(x)
  means <- format(sort(c(t_tbl$estimate1, t_tbl$estimate2)), digits = 1, nsmall = 1)
  paste0("means of ",
         paste(means, collapse = " and "), "; ",
         "$t(", round(t_tbl$parameter, 1), ") = ",
         round(abs(t_tbl$statistic), 2), "$, ",
         apa_p(t_tbl$p.value))
}

#' Report a p-value in APA format
#'
#' @param x The p-value.
#' @return A string with formatted p-value.
#' @export
apa_p <- function(x) {
  paste0("$p ",
         dplyr::case_when(x < .001 ~ "< .001",
                   x > .9994 ~ "> .999",
                   TRUE ~ sprintf("= %0.3f", x)),
         "$")
}

#' Are table column values the same class
#'
#' @param subtbl name of submission table
#' @param subcol name of column in submission table
#' @param sol_env solution environment
#' @param soltbl name of table of solution environment
#' @param solcol name of column in solution table
#' @param add whether to add feedback
#' @return logical
#' @export
tbl_cols_sameclass <- function(subtbl, subcol,
                               sol_env,
                               soltbl = subtbl,
                               solcol = subcol,
                               add = TRUE) {
  res <- FALSE
  sol_tbl <- get(soltbl, envir = sol_env)
  sub_tbl <- safe_get_table(subtbl, parent.frame(), add)
  if (!is.null(sub_tbl)) {
    if (!(subcol %in% colnames(sub_tbl))) {
      add_feedback("* column name `", subcol, "` was missing from your table `",
                   subtbl, "`", add = add)
    } else {
      res <- all(mapply(`==`, class(sub_tbl[[subcol]]),
                        class(sol_tbl[[solcol]])))
      if (!res) {
        add_feedback("* data type of column `", subcol,
                     "` in `", subtbl, "` was incorrect",
                     add = add)
      } else {
        add_feedback("* data type of column `", subcol, "` of `", subtbl,
                     "` matched solution table",
                     add = add)
      }
    }
  }
  res
}

#' Score multiple-choice question
#'
#' Score a multiple-choice question.
#'
#' @param subvar Name of the submission variable
#' @param sol_env Solution environment
#' @param opts_all Vector whose elements comprise the full set of options (e.g., "a", "b", "c", "d")
#' @param solvar Name of the solution variable
#' @param rectify Whether to attempt to correct the format of the submitted answer
#' @param add whether to add feedback
#' @return vector of logicals, one for each option, plus "type" and "fmt_ok" to indicate correctly formatted answer
#' @export
score_mcq <- function(subvar, sol_env, opts_all,
                      solvar = subvar, rectify = TRUE,
                      add = TRUE) {
  res <- c("type" = FALSE,
           "fmt_ok" = FALSE)
  vals <- rep(FALSE, length(opts_all))
  names(vals) <- paste0("o", seq_along(opts_all))

  sol_val <- get(solvar, envir = sol_env)
  sol2 <- tolower(sol_val) |> sort()
  
  if (!exists(subvar, envir = parent.frame(), inherits = FALSE)) {
    add_feedback(paste0("* you did not define `", subvar, "`"),
                 add = add)
  } else {
    sub_val <- get(subvar, envir = parent.frame(), inherits = FALSE)
    if (!is.vector(sub_val)) {
      add_feedback(paste0("* `", subvar, "` was not a vector"),
                   add = add)
    } else {
      if (class(sub_val) != class(sol_val)) {
        add_feedback(paste0("* `", subvar, "` was not of type '",
                            class(sol_val), "'"), add = add)
      } else {
        res["type"] <- TRUE
        sub2 <- tolower(sub_val) |> sort()
        oall2 <- tolower(opts_all) |> sort()

        ## check whether format is wrong, e.g.,
        ## v <- "a, b, c" instead of v <- c("a", "b", "c")
        sub3 <- if (rectify) {
                  ## get rid of any white space
                  resp_nows <- gsub("[[:space:]]", "", sub2)
                  ## get rid of non-letter characters
                  resp_lonly <- gsub("[^a-z]", "", sub2)
                  ## collapse and then split
                  resp_coll <- paste(resp_lonly, collapse = "")
                  resp_corr <- strsplit(resp_coll, "")[[1]]
                  res["fmt_ok"] <- isTRUE(identical(resp_nows, resp_corr))
                  if (!res["fmt_ok"]) {
                    add_feedback("* response formatted incorrectly; reformatted to ",
                                 sprintf("`%s <- c(\"%s\")`", subvar, paste(resp_corr, collapse = "\", \"")),
                                 add=add)
                  }
                  resp_corr
                } else {
                  sub2
                }
        ## check whether values match
        vals[] <- sapply(oall2, \(.x) (.x %in% sol2) == (.x %in% sub3))
      }
    }
  }

  if (all(vals)) {
    add_feedback("* answer matched solution", add = add)
  } else {
    add_feedback("* answer did not match solution ('",
                 paste(sol2, collapse = "', '"), "')",
                 add = add)
  }
  
  return(c(res, vals))
}
dalejbarr/assessr documentation built on Jan. 25, 2024, 10:47 p.m.