diff --git a/DESCRIPTION b/DESCRIPTION index 8569dcb..bc4c407 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,8 +1,8 @@ Package: jaspSyntax Type: Package Title: Makes JASP analyses available in R -Version: 1.3.2 -Date: 2026-05-11 +Version: 1.3.3 +Date: 2026-06-04 Author: JASP Team Website: jasp-stats.org Maintainer: JASP Team diff --git a/NAMESPACE b/NAMESPACE index 98bd8ab..a8d3b0e 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -6,9 +6,10 @@ export(clearDatasetState) export(clearNativeState) export(clearQmlForms) export(cleanUp) -export(columnMapping) +export(columnEncoderContext) export(decodeAnalysisResults) export(decodeColumnNames) +export(decodeColumnText) export(generateAnalysisWrapper) export(generateModuleWrappers) export(getVariableNames) diff --git a/R/RcppExports.R b/R/RcppExports.R index 5ffd922..46a21da 100644 --- a/R/RcppExports.R +++ b/R/RcppExports.R @@ -21,8 +21,8 @@ clearNativeStateNative <- function() { invisible(.Call(`_jaspSyntax_clearNativeStateNative`)) } -setParameter <- function(name, value) { - .Call(`_jaspSyntax_setParameter`, name, value) +setParameterNative <- function(name, value) { + .Call(`_jaspSyntax_setParameterNative`, name, value) } loadDataSet <- function(data) { @@ -57,3 +57,11 @@ getVariableNames <- function() { .Call(`_jaspSyntax_getVariableNames`) } +columnEncoderContextNative <- function() { + .Call(`_jaspSyntax_columnEncoderContextNative`) +} + +decodeColumnTextNative <- function(values, encoderContextJson) { + .Call(`_jaspSyntax_decodeColumnTextNative`, values, encoderContextJson) +} + diff --git a/R/bridgeSubprocess.R b/R/bridgeSubprocess.R index ed5703b..4549d82 100644 --- a/R/bridgeSubprocess.R +++ b/R/bridgeSubprocess.R @@ -117,7 +117,7 @@ } .bridgeSubprocessPackageLoader <- function() { - function(packageSpec) { + loader <- function(packageSpec) { pathEntries <- function(path = Sys.getenv("PATH", unset = "")) { entries <- strsplit(path, .Platform$path.sep, fixed = TRUE)[[1L]] normalizePath(entries[nzchar(entries)], winslash = "/", mustWork = FALSE) @@ -146,10 +146,13 @@ character(0) } buildDirs <- buildDirs[dir.exists(buildDirs)] - Sys.setenv(PATH = paste(unique(c(dllDirs, buildDirs, currentPathEntries)), collapse = .Platform$path.sep)) + Sys.setenv(PATH = paste(unique(c(buildDirs, dllDirs, currentPathEntries)), collapse = .Platform$path.sep)) message("jaspSyntax subprocess source package: ", packagePath) message("jaspSyntax subprocess DLL dirs: ", paste(dllDirs, collapse = ";")) - message("jaspSyntax subprocess PATH head: ", paste(head(strsplit(Sys.getenv("PATH"), .Platform$path.sep, fixed = TRUE)[[1L]], 8L), collapse = ";")) + if (length(buildDirs) > 0L) { + message("jaspSyntax subprocess build DLL dirs: ", paste(buildDirs, collapse = ";")) + } + message("jaspSyntax subprocess PATH head: ", paste(utils::head(strsplit(Sys.getenv("PATH"), .Platform$path.sep, fixed = TRUE)[[1L]], 8L), collapse = ";")) } if (!requireNamespace("pkgload", quietly = TRUE)) { @@ -161,6 +164,26 @@ suppressPackageStartupMessages(library(jaspSyntax)) } } + environment(loader) <- baseenv() + loader +} + +.bridgeSubprocessRunner <- function() { + runner <- function(target, input, packageSpec, loadPackage, resultPath) { + result <- tryCatch( + { + loadPackage(packageSpec) + do.call(getNamespace("jaspSyntax")[[target]], input) + }, + error = function(e) { + structure(list(message = conditionMessage(e)), class = "jaspSyntax_subprocess_error") + } + ) + saveRDS(result, resultPath) + invisible(NULL) + } + environment(runner) <- baseenv() + runner } .readBridgeSubprocessOutput <- function(stdoutPath, stderrPath) { @@ -181,27 +204,20 @@ .runBridgeSubprocess <- function(task, target, input, failureLabel) { stdoutPath <- tempfile(paste0("jaspSyntax_", task, "_"), fileext = ".out") stderrPath <- tempfile(paste0("jaspSyntax_", task, "_"), fileext = ".err") - on.exit(unlink(c(stdoutPath, stderrPath)), add = TRUE) + resultPath <- tempfile(paste0("jaspSyntax_", task, "_"), fileext = ".rds") + on.exit(unlink(c(stdoutPath, stderrPath, resultPath)), add = TRUE) packageSpec <- .bridgeSubprocessPackageSpec() + launchError <- NULL - result <- tryCatch( + tryCatch( callr::r( - func = function(target, input, packageSpec, loadPackage) { - tryCatch( - { - loadPackage(packageSpec) - do.call(getNamespace("jaspSyntax")[[target]], input) - }, - error = function(e) { - structure(list(message = conditionMessage(e)), class = "jaspSyntax_subprocess_error") - } - ) - }, + func = .bridgeSubprocessRunner(), args = list( target = target, input = input, packageSpec = packageSpec, - loadPackage = .bridgeSubprocessPackageLoader() + loadPackage = .bridgeSubprocessPackageLoader(), + resultPath = resultPath ), libpath = .libPaths(), stdout = stdoutPath, @@ -211,12 +227,23 @@ error = "error" ), error = function(e) { - structure(list(message = conditionMessage(e)), class = "jaspSyntax_subprocess_error") + launchError <<- e + NULL } ) output <- .readBridgeSubprocessOutput(stdoutPath, stderrPath) outputSuffix <- .bridgeSubprocessOutputSuffix(output) + result <- if (file.exists(resultPath)) { + readRDS(resultPath) + } else { + message <- if (!is.null(launchError)) { + conditionMessage(launchError) + } else { + "subprocess did not return a result" + } + structure(list(message = message), class = "jaspSyntax_subprocess_error") + } if (inherits(result, "jaspSyntax_subprocess_error")) { stop( diff --git a/R/columnDecoder.R b/R/columnDecoder.R new file mode 100644 index 0000000..7139393 --- /dev/null +++ b/R/columnDecoder.R @@ -0,0 +1,153 @@ +#' Capture a Native Column Encoder Context +#' +#' Captures the source state needed by SyntaxInterface to reconstruct the native +#' `ColumnEncoder`: dataset column names/types and extra QML option encodings. +#' The context can be reused after the active native dataset changes. +#' +#' @return A serializable column encoder context. +#' +#' @export +columnEncoderContext <- function() { + rawContext <- columnEncoderContextNative() + .columnEncoderContextFromJson(rawContext) +} + +#' Decode Text With a Native Column Encoder Context +#' +#' Decodes embedded JASP column tokens using SyntaxInterface's native +#' `ColumnEncoder` replacement rules. +#' +#' @param text Character vector to decode. +#' @param encoderContext Optional context returned by `columnEncoderContext()`. +#' When omitted, the current native bridge encoder state is used. +#' +#' @return A character vector with native column tokens decoded. +#' +#' @export +decodeColumnText <- function(text, encoderContext = NULL) { + if (!is.character(text)) { + stop("`text` must be a character vector", call. = FALSE) + } + if (!.containsEncodedBridgeColumnTokens(text)) { + return(text) + } + + contextJson <- .columnEncoderContextJson(encoderContext) + decoded <- tryCatch( + decodeColumnTextNative(text, contextJson), + error = function(e) { + stop( + "Native column decoder failed: ", + conditionMessage(e), + call. = FALSE + ) + } + ) + if (!is.character(decoded) || length(decoded) != length(text)) { + stop("Native column decoder returned an invalid result.", call. = FALSE) + } + + names(decoded) <- names(text) + decoded +} + +.columnEncoderContextFromJson <- function(rawContext) { + if (!is.character(rawContext) || length(rawContext) != 1L || is.na(rawContext)) { + stop("Native column encoder context must be a single JSON string.", call. = FALSE) + } + + parsed <- jsonlite::fromJSON(rawContext, simplifyVector = FALSE) + .newColumnEncoderContext( + rawContext = rawContext, + columns = .normalizeColumnEncoderContextColumns(parsed[["columns"]]), + extra = .normalizeColumnEncoderContextColumns(parsed[["extra"]]) + ) +} + +.newColumnEncoderContext <- function(rawContext = NULL, columns = list(), extra = list()) { + columns <- .normalizeColumnEncoderContextColumns(columns) + extra <- .normalizeColumnEncoderContextColumns(extra) + + if (is.null(rawContext)) { + rawContext <- as.character(jsonlite::toJSON( + list(version = 1L, columns = columns, extra = extra), + auto_unbox = TRUE, + null = "null" + )) + } + + structure( + list( + version = 1L, + columns = columns, + extra = extra, + native = rawContext + ), + class = "jaspSyntaxColumnEncoderContext" + ) +} + +.normalizeColumnEncoderContextColumns <- function(columns = NULL) { + if (is.null(columns) || length(columns) == 0L) { + return(list()) + } + + if (is.data.frame(columns)) { + columns <- split(columns, seq_len(nrow(columns))) + } + + if (!is.list(columns)) { + stop("Column encoder context columns must be a list.", call. = FALSE) + } + + lapply(columns, function(column) { + if (!is.list(column) || is.null(column[["name"]]) || is.null(column[["type"]])) { + stop("Column encoder context entries must contain `name` and `type`.", call. = FALSE) + } + + name <- column[["name"]] + type <- column[["type"]] + if (!is.character(name) || length(name) != 1L || is.na(name) || !nzchar(name) || + !is.character(type) || length(type) != 1L || is.na(type) || !nzchar(type)) { + stop("Column encoder context `name` and `type` entries must be non-empty strings.", call. = FALSE) + } + + list(name = name, type = type) + }) +} + +.columnEncoderContextJson <- function(encoderContext = NULL) { + if (is.null(encoderContext)) { + return("") + } + + if (inherits(encoderContext, "jaspSyntaxColumnEncoderContext")) { + return(encoderContext[["native"]]) + } + + if (is.character(encoderContext) && length(encoderContext) == 1L && !is.na(encoderContext)) { + return(encoderContext) + } + + if (is.list(encoderContext) && + (!is.null(encoderContext[["columns"]]) || !is.null(encoderContext[["extra"]]))) { + return(.newColumnEncoderContext( + columns = encoderContext[["columns"]], + extra = encoderContext[["extra"]] + )[["native"]]) + } + + stop("`encoderContext` must be a native column encoder context.", call. = FALSE) +} + +.containsEncodedBridgeColumnTokens <- function(text) { + if (!is.character(text) || length(text) == 0L) { + return(FALSE) + } + + any(grepl( + "(JaspColumn_[[:alnum:]_]+_Encoded|JaspExtraOptions_[[:alnum:]_]+_Encoded|jaspColumn[0-9]+)", + text, + perl = TRUE + ), na.rm = TRUE) +} diff --git a/R/options.R b/R/options.R index 632c6a7..9653d6c 100644 --- a/R/options.R +++ b/R/options.R @@ -667,15 +667,6 @@ parseQmlOptions <- function(qmlFile, options = NULL, moduleName = "jaspModule", preloadData = preloadData ) - if (!is.character(rawOptions) || length(rawOptions) != 1L || !nzchar(rawOptions)) { - stop( - "jaspSyntax::loadQmlAndParseOptions() failed for QML file `", - qmlFile, - "`", - call. = FALSE - ) - } - if (identical(output, "json")) { return(rawOptions) } diff --git a/R/parameters.R b/R/parameters.R new file mode 100644 index 0000000..6c76577 --- /dev/null +++ b/R/parameters.R @@ -0,0 +1,36 @@ +.normalizeVerboseParameter <- function(value) { + if (is.null(value) || length(value) == 0L || is.na(value[[1L]])) + stop("`verbose` must be one of 'all', 'analysis', 'jasp', 'none', TRUE, or FALSE.", call. = FALSE) + + value <- value[[1L]] + if (is.logical(value)) + return(if (isTRUE(value)) "all" else "analysis") + + if (is.character(value)) { + value <- tolower(trimws(value)) + if (value %in% c("true", "yes", "on", "1")) + return("all") + if (value %in% c("false", "no", "off", "0")) + return("analysis") + if (value %in% c("all", "analysis", "jasp", "none")) + return(value) + } + + stop("`verbose` must be one of 'all', 'analysis', 'jasp', 'none', TRUE, or FALSE.", call. = FALSE) +} + +.verboseParameterShowsNativeOutput <- function(verbose) { + verbose %in% c("all", "jasp") +} + +#' @export +setParameter <- function(name, value) { + if (identical(as.character(name), "verbose")) { + verbose <- .normalizeVerboseParameter(value) + result <- setParameterNative(name, .verboseParameterShowsNativeOutput(verbose)) + options(jaspSyntax.verbose = verbose) + return(result) + } + + setParameterNative(name, value) +} diff --git a/R/readDatasetFromJaspFile.R b/R/readDatasetFromJaspFile.R index 4826c69..eff715c 100644 --- a/R/readDatasetFromJaspFile.R +++ b/R/readDatasetFromJaspFile.R @@ -198,12 +198,13 @@ #' Decode Native JASP Column Names #' #' Decodes column names using the native bridge decoder installed by -#' SyntaxInterface. When the bridge does not expose a decoder, the default is to -#' return names unchanged so callers can still operate on non-encoded inputs. +#' SyntaxInterface. Raw/non-encoded names are returned unchanged; encoded bridge +#' names require a working native decoder. #' #' @param columnNames Character vector of column names. -#' @param strict Whether to fail when an encoded bridge name cannot be decoded. -#' Raw/non-encoded names are returned unchanged. +#' @param strict Compatibility-only flag retained for callers that pass it. +#' Encoded bridge names always require native decoding; raw/non-encoded names +#' are returned unchanged. #' #' @return A character vector with decoded names. #' @@ -219,39 +220,35 @@ decodeColumnNames <- function(columnNames, strict = FALSE) { return(columnNames) } - decodeName <- get0(".decodeColNamesStrict", envir = .GlobalEnv, inherits = FALSE) - if (!is.function(decodeName)) { - if (strict) { + decodedNames <- columnNames + nativeDecoded <- tryCatch( + decodeColumnText(columnNames[encoded]), + error = function(e) { stop( - "jaspSyntax bridge did not expose `.decodeColNamesStrict`", + "jaspSyntax bridge could not decode native column names: ", + conditionMessage(e), call. = FALSE ) } - return(columnNames) + ) + + if (!is.character(nativeDecoded) || length(nativeDecoded) != sum(encoded)) { + stop( + "Native column decoder returned an invalid column-name result.", + call. = FALSE + ) } - decodedNames <- columnNames - decodedNames[encoded] <- vapply(columnNames[encoded], function(columnName) { - tryCatch( - { - decoded <- as.character(decodeName(columnName)) - if (length(decoded) != 1L || is.na(decoded)) { - stop("decoder returned an empty value") - } - decoded - }, - error = function(e) { - if (strict) { - stop( - "Could not decode column name `", columnName, "`: ", - conditionMessage(e), - call. = FALSE - ) - } - columnName - } + decodedNames[encoded] <- nativeDecoded + stillEncoded <- .isEncodedBridgeColumnName(decodedNames[encoded]) + if (any(stillEncoded)) { + failed <- columnNames[encoded][stillEncoded][[1L]] + stop( + "Native column decoder left encoded column name `", failed, "` unchanged.", + call. = FALSE ) - }, character(1L), USE.NAMES = FALSE) + } + decodedNames } @@ -260,31 +257,6 @@ decodeColumnNames <- function(columnNames, strict = FALSE) { grepl("^jaspColumn[0-9]+$", columnNames) } -#' @rdname decodeColumnNames -#' @param encodedColumnNames Optional encoded column names. When omitted, the -#' current native dataset header is used. -#' -#' @return `columnMapping()` returns a named character vector mapping encoded -#' names to decoded names. -#' -#' @export -columnMapping <- function(encodedColumnNames = NULL, strict = FALSE) { - strict <- .validateFlag(strict, "strict") - - if (is.null(encodedColumnNames)) { - encodedColumnNames <- readDatasetHeader(decode = FALSE)$encodedName - } - - if (!is.character(encodedColumnNames)) { - stop("`encodedColumnNames` must be a character vector", call. = FALSE) - } - - stats::setNames( - decodeColumnNames(encodedColumnNames, strict = strict), - encodedColumnNames - ) -} - #' Read the Loaded Native Dataset #' #' Reads the full dataset currently loaded into the native SyntaxInterface @@ -364,8 +336,8 @@ readDatasetHeader <- function(decode = TRUE) { #' @inheritParams readLoadedDataset #' #' @return A list with `loadedDataset`, `requestedDataset`, -#' `resultDecodingDataset`, `runtimeOptions`, `columnMapping`, `modulePath`, -#' and `analysisName`. +#' `resultDecodingDataset`, `runtimeOptions`, `columnEncoderContext`, +#' `modulePath`, and `analysisName`. #' #' @export loadAnalysisDataset <- function(dataset, modulePath, analysisName, options = NULL, @@ -405,7 +377,6 @@ loadAnalysisDataset <- function(dataset, modulePath, analysisName, options = NUL loadedRaw <- .readBridgeDataset(".readFullDatasetToEnd", "loaded dataset") requestedRaw <- .readBridgeDataset(".readDataSetRequestedNative", "requested dataset") - rawColumnNames <- unique(c(names(loadedRaw), names(requestedRaw))) state <- list( loadedDataset = .prepareBridgeDataset( @@ -424,7 +395,7 @@ loadAnalysisDataset <- function(dataset, modulePath, analysisName, options = NUL normalize = FALSE ), runtimeOptions = runtimeOptions, - columnMapping = columnMapping(rawColumnNames, strict = decode), + columnEncoderContext = columnEncoderContext(), modulePath = modulePath, analysisName = analysisName ) diff --git a/R/resultDecoding.R b/R/resultDecoding.R index 9ac8623..b31b3f3 100644 --- a/R/resultDecoding.R +++ b/R/resultDecoding.R @@ -1,36 +1,34 @@ #' Decode JASP Analysis Result Payloads #' -#' Decodes native column-name tokens and factor value tokens in analysis results -#' using the current SyntaxInterface dataset state. +#' Decodes native column-name tokens through SyntaxInterface and factor value +#' tokens from the requested dataset used by the analysis. #' #' @param results A result payload list, typically decoded from jaspResults JSON. #' @param requestedDataset Optional requested dataset to use as the factor-label #' source. When omitted, the current native requested dataset is read from the #' bridge if available. -#' @param columnMapping Optional named character vector mapping encoded native -#' column names to decoded user-facing column names. Supplying this avoids a -#' late native decoder call after analysis execution. +#' @param columnEncoderContext Optional context returned by +#' `columnEncoderContext()`. Supplying it lets result replay decode with the +#' dataset/module state that created the result even after native state changes. #' #' @return The result payload with decoded column names and factor values. #' #' @export decodeAnalysisResults <- function(results, requestedDataset = NULL, - columnMapping = NULL) { + columnEncoderContext = NULL) { if (!is.list(results)) { return(results) } decodeContext <- .analysisResultDecodeContext( requestedDataset, - columnMapping = columnMapping + columnEncoderContext = columnEncoderContext ) .decodeAnalysisResultObject(results, decodeContext = decodeContext) } .analysisResultDecodeContext <- function(requestedDataset = NULL, - columnMapping = NULL) { - columnMapping <- .validateAnalysisResultColumnMapping(columnMapping) - + columnEncoderContext = NULL) { if (is.null(requestedDataset)) { requestedDataset <- tryCatch( readRequestedDataset(decode = FALSE, normalize = FALSE), @@ -38,8 +36,21 @@ decodeAnalysisResults <- function(results, requestedDataset = NULL, ) } + factorValues <- .analysisResultFactorValues( + requestedDataset, + columnEncoderContext = columnEncoderContext + ) + + list( + factorValues = factorValues, + columnEncoderContext = columnEncoderContext + ) +} + +.analysisResultFactorValues <- function(requestedDataset = NULL, + columnEncoderContext = NULL) { if (!is.data.frame(requestedDataset)) { - return(list(factorValues = list(), columnMapping = columnMapping)) + return(list()) } factorValues <- list() @@ -50,15 +61,8 @@ decodeAnalysisResults <- function(results, requestedDataset = NULL, } valueMap <- stats::setNames(levels(column), as.character(seq_along(levels(column)))) - decodedName <- tryCatch( - .decodeAnalysisResultColumnNames(columnName, columnMapping), - error = function(e) columnName - ) - columnKeys <- unique(c( - columnName, - decodedName, - .encodedAnalysisResultColumnNames(columnName, columnMapping) - )) + decodedName <- .decodeAnalysisResultColumnNames(columnName, columnEncoderContext) + columnKeys <- unique(c(columnName, decodedName)) for (columnKey in columnKeys) { if (is.character(columnKey) && length(columnKey) == 1L && nzchar(columnKey)) { @@ -67,10 +71,18 @@ decodeAnalysisResults <- function(results, requestedDataset = NULL, } } - list(factorValues = factorValues, columnMapping = columnMapping) + factorValues } .decodeAnalysisResultObject <- function(x, fieldName = NULL, decodeContext) { + if (isS4(x) || is.call(x) || is.name(x)) { + return(x) + } + + if (is.object(x) && !is.data.frame(x)) { + return(x) + } + if (is.list(x)) { oldNames <- names(x) for (i in seq_len(length(x))) { @@ -80,10 +92,7 @@ decodeAnalysisResults <- function(results, requestedDataset = NULL, } if (!is.null(oldNames)) { - names(x) <- .decodeAnalysisResultColumnNames( - oldNames, - decodeContext[["columnMapping"]] - ) + names(x) <- .decodeAnalysisResultColumnNames(oldNames, decodeContext[["columnEncoderContext"]]) } return(x) @@ -92,21 +101,34 @@ decodeAnalysisResults <- function(results, requestedDataset = NULL, x <- .decodeAnalysisResultFactorValues(x, fieldName, decodeContext) if (is.character(x)) { - x <- .decodeAnalysisResultColumnNames( - x, - decodeContext[["columnMapping"]] - ) + x <- .decodeAnalysisResultColumnNames(x, decodeContext[["columnEncoderContext"]]) } x } .decodeAnalysisResultFactorValues <- function(x, fieldName, decodeContext) { - if (is.null(fieldName) || is.null(decodeContext[["factorValues"]][[fieldName]])) { + if (is.null(fieldName)) { + return(x) + } + + candidateFields <- unique(c( + fieldName, + .decodeAnalysisResultColumnNames(fieldName, decodeContext[["columnEncoderContext"]]) + )) + candidateFields <- candidateFields[!is.na(candidateFields) & nzchar(candidateFields)] + + valueMap <- NULL + for (candidateField in candidateFields) { + valueMap <- decodeContext[["factorValues"]][[candidateField]] + if (!is.null(valueMap)) { + break + } + } + if (is.null(valueMap)) { return(x) } - valueMap <- decodeContext[["factorValues"]][[fieldName]] key <- as.character(x) matched <- key %in% names(valueMap) if (!any(matched)) { @@ -118,41 +140,10 @@ decodeAnalysisResults <- function(results, requestedDataset = NULL, out } -.validateAnalysisResultColumnMapping <- function(columnMapping = NULL) { - if (is.null(columnMapping)) { - return(NULL) - } - - if (!is.character(columnMapping) || is.null(names(columnMapping))) { - stop("`columnMapping` must be a named character vector", call. = FALSE) - } - - valid <- !is.na(columnMapping) & nzchar(columnMapping) & - !is.na(names(columnMapping)) & nzchar(names(columnMapping)) - columnMapping[valid] -} - -.decodeAnalysisResultColumnNames <- function(columnNames, columnMapping = NULL) { +.decodeAnalysisResultColumnNames <- function(columnNames, columnEncoderContext = NULL) { if (!is.character(columnNames) || length(columnNames) == 0L) { return(columnNames) } - if (length(columnMapping) > 0L) { - decoded <- unname(columnMapping[columnNames]) - matched <- !is.na(decoded) - columnNames[matched] <- decoded[matched] - return(columnNames) - } - - decodeColumnNames(columnNames, strict = FALSE) -} - -.encodedAnalysisResultColumnNames <- function(decodedColumnName, - columnMapping = NULL) { - if (!is.character(decodedColumnName) || length(decodedColumnName) != 1L || - length(columnMapping) == 0L) { - return(character(0)) - } - - names(columnMapping)[!is.na(columnMapping) & columnMapping == decodedColumnName] + decodeColumnText(columnNames, columnEncoderContext) } diff --git a/man/columnEncoderContext.Rd b/man/columnEncoderContext.Rd new file mode 100644 index 0000000..2be7dd4 --- /dev/null +++ b/man/columnEncoderContext.Rd @@ -0,0 +1,26 @@ +\name{columnEncoderContext} +\alias{columnEncoderContext} +\alias{decodeColumnText} +\title{Native Column Encoder Context} +\usage{ +columnEncoderContext() + +decodeColumnText(text, encoderContext = NULL) +} +\arguments{ +\item{text}{Character vector to decode.} + +\item{encoderContext}{Optional context returned by +\code{columnEncoderContext()}. When omitted, the current native bridge encoder +state is used.} +} +\value{ +\code{columnEncoderContext()} returns a serializable native encoder context. +\code{decodeColumnText()} returns a character vector with native column tokens +decoded. +} +\description{ +Capture and reuse the source state needed by SyntaxInterface's native +\code{ColumnEncoder}: dataset column names/types and extra QML option +encodings. +} diff --git a/man/datasetBridgeHelpers.Rd b/man/datasetBridgeHelpers.Rd index 4b0f3db..b069222 100644 --- a/man/datasetBridgeHelpers.Rd +++ b/man/datasetBridgeHelpers.Rd @@ -4,7 +4,6 @@ \alias{readRequestedDataset} \alias{readDatasetHeader} \alias{decodeColumnNames} -\alias{columnMapping} \title{High-Level Native Dataset Helpers} \usage{ loadAnalysisDataset( @@ -25,8 +24,6 @@ readRequestedDataset(decode = TRUE, normalize = TRUE) readDatasetHeader(decode = TRUE) decodeColumnNames(columnNames, strict = FALSE) - -columnMapping(encodedColumnNames = NULL, strict = FALSE) } \arguments{ \item{dataset}{Raw data frame supplied by the caller.} @@ -47,21 +44,20 @@ columnMapping(encodedColumnNames = NULL, strict = FALSE) \item{columnNames}{Character vector of column names.} -\item{strict}{Whether to fail when an encoded bridge name cannot be decoded. Raw/non-encoded names are returned unchanged.} - -\item{encodedColumnNames}{Optional encoded column names. When omitted, the current native dataset header is used.} +\item{strict}{Compatibility-only flag retained for callers that pass it. +Encoded bridge names always require native decoding; raw/non-encoded names are +returned unchanged.} } \value{ \code{loadAnalysisDataset()} returns a list with \code{loadedDataset}, \code{requestedDataset}, \code{resultDecodingDataset}, -\code{runtimeOptions}, \code{columnMapping}, \code{modulePath}, and +\code{runtimeOptions}, \code{columnEncoderContext}, \code{modulePath}, and \code{analysisName}. \code{readLoadedDataset()} and \code{readRequestedDataset()} return data frames. \code{readDatasetHeader()} returns a data frame with \code{name} and \code{encodedName} columns. \code{decodeColumnNames()} returns a character -vector. \code{columnMapping()} returns a named character vector mapping encoded -names to decoded names. +vector. } \description{ These helpers keep native dataset preload, requested-data state, and column-name @@ -71,9 +67,8 @@ option preparation. } \details{ \code{loadAnalysisDataset()} is the supported high-level entry point for -jaspTools-style replay. The direct state readers and column mapping helpers are -exported for bridge diagnostics and should be treated as native-facing -integration APIs. +jaspTools-style replay. The direct state readers are exported for bridge +diagnostics and should be treated as native-facing integration APIs. \code{loadAnalysisDataset()} loads the raw data frame into SyntaxInterface, replays saved/QML-bound options through \code{readAnalysisOptionsFromQml()} with diff --git a/man/decodeAnalysisResults.Rd b/man/decodeAnalysisResults.Rd index 64f3811..371e262 100644 --- a/man/decodeAnalysisResults.Rd +++ b/man/decodeAnalysisResults.Rd @@ -4,7 +4,11 @@ \alias{decodeAnalysisResults} \title{Decode JASP Analysis Result Payloads} \usage{ -decodeAnalysisResults(results, requestedDataset = NULL, columnMapping = NULL) +decodeAnalysisResults( + results, + requestedDataset = NULL, + columnEncoderContext = NULL +) } \arguments{ \item{results}{A result payload list, typically decoded from jaspResults JSON.} @@ -13,14 +17,14 @@ decodeAnalysisResults(results, requestedDataset = NULL, columnMapping = NULL) source. When omitted, the current native requested dataset is read from the bridge if available.} -\item{columnMapping}{Optional named character vector mapping encoded native -column names to decoded user-facing column names. Supplying this avoids a -late native decoder call after analysis execution.} +\item{columnEncoderContext}{Optional context returned by +\code{columnEncoderContext()}. Supplying it lets result replay decode with the +dataset/module state that created the result even after native state changes.} } \value{ The result payload with decoded column names and factor values. } \description{ -Decodes native column-name tokens and factor value tokens in analysis results -using the current SyntaxInterface dataset state. +Decodes native column-name tokens through SyntaxInterface and factor value +tokens from the requested dataset used by the analysis. } diff --git a/man/nativeBridge.Rd b/man/nativeBridge.Rd index 71261cd..d465a0d 100644 --- a/man/nativeBridge.Rd +++ b/man/nativeBridge.Rd @@ -74,6 +74,18 @@ package code should prefer the higher-level helpers such as \code{readRequestedDataset()}, \code{readAnalysisOptionsFromJaspFile()}, and \code{readDatasetFromJaspFile()}. +\code{setParameter("verbose", TRUE)} or \code{setParameter("verbose", "jasp")} +enables native SyntaxInterface logging for debugging. For wrapped analyses, +\code{setParameter("verbose", value)} also sets the default verbosity honored by +\code{jaspBase} and \code{jaspTools}: \code{"all"} shows native and analysis +output, \code{"analysis"} shows analysis messages and warnings only, +\code{"jasp"} shows native output only, and \code{"none"} suppresses both. +For compatibility with the old native toggle, +\code{setParameter("verbose", FALSE)} disables native logging while keeping +analysis diagnostics visible. Native logging is disabled by default and can also +be enabled before bridge initialization with the \code{JASP_SYNTAX_VERBOSE=1} +environment variable. + \code{cleanUp()} clears the native state and then runs the legacy cleanup hook. Use \code{\link{clearQmlForms}}, \code{\link{clearDatasetState}}, or \code{\link{clearNativeState}} when the intended lifecycle scope matters. diff --git a/src/RcppExports.cpp b/src/RcppExports.cpp index f22fd56..5f08500 100644 --- a/src/RcppExports.cpp +++ b/src/RcppExports.cpp @@ -55,15 +55,15 @@ BEGIN_RCPP return R_NilValue; END_RCPP } -// setParameter -bool setParameter(String name, SEXP value); -RcppExport SEXP _jaspSyntax_setParameter(SEXP nameSEXP, SEXP valueSEXP) { +// setParameterNative +bool setParameterNative(String name, SEXP value); +RcppExport SEXP _jaspSyntax_setParameterNative(SEXP nameSEXP, SEXP valueSEXP) { BEGIN_RCPP Rcpp::RObject rcpp_result_gen; Rcpp::RNGScope rcpp_rngScope_gen; Rcpp::traits::input_parameter< String >::type name(nameSEXP); Rcpp::traits::input_parameter< SEXP >::type value(valueSEXP); - rcpp_result_gen = Rcpp::wrap(setParameter(name, value)); + rcpp_result_gen = Rcpp::wrap(setParameterNative(name, value)); return rcpp_result_gen; END_RCPP } @@ -159,6 +159,28 @@ BEGIN_RCPP return rcpp_result_gen; END_RCPP } +// columnEncoderContextNative +String columnEncoderContextNative(); +RcppExport SEXP _jaspSyntax_columnEncoderContextNative() { +BEGIN_RCPP + Rcpp::RObject rcpp_result_gen; + Rcpp::RNGScope rcpp_rngScope_gen; + rcpp_result_gen = Rcpp::wrap(columnEncoderContextNative()); + return rcpp_result_gen; +END_RCPP +} +// decodeColumnTextNative +Rcpp::CharacterVector decodeColumnTextNative(Rcpp::CharacterVector values, String encoderContextJson); +RcppExport SEXP _jaspSyntax_decodeColumnTextNative(SEXP valuesSEXP, SEXP encoderContextJsonSEXP) { +BEGIN_RCPP + Rcpp::RObject rcpp_result_gen; + Rcpp::RNGScope rcpp_rngScope_gen; + Rcpp::traits::input_parameter< Rcpp::CharacterVector >::type values(valuesSEXP); + Rcpp::traits::input_parameter< String >::type encoderContextJson(encoderContextJsonSEXP); + rcpp_result_gen = Rcpp::wrap(decodeColumnTextNative(values, encoderContextJson)); + return rcpp_result_gen; +END_RCPP +} static const R_CallMethodDef CallEntries[] = { {"_jaspSyntax_cleanUp", (DL_FUNC) &_jaspSyntax_cleanUp, 0}, @@ -166,7 +188,7 @@ static const R_CallMethodDef CallEntries[] = { {"_jaspSyntax_clearQmlFormsNative", (DL_FUNC) &_jaspSyntax_clearQmlFormsNative, 0}, {"_jaspSyntax_clearDatasetStateNative", (DL_FUNC) &_jaspSyntax_clearDatasetStateNative, 0}, {"_jaspSyntax_clearNativeStateNative", (DL_FUNC) &_jaspSyntax_clearNativeStateNative, 0}, - {"_jaspSyntax_setParameter", (DL_FUNC) &_jaspSyntax_setParameter, 2}, + {"_jaspSyntax_setParameterNative", (DL_FUNC) &_jaspSyntax_setParameterNative, 2}, {"_jaspSyntax_loadDataSet", (DL_FUNC) &_jaspSyntax_loadDataSet, 1}, {"_jaspSyntax_loadQmlAndParseOptions", (DL_FUNC) &_jaspSyntax_loadQmlAndParseOptions, 6}, {"_jaspSyntax_generateModuleWrappers", (DL_FUNC) &_jaspSyntax_generateModuleWrappers, 1}, @@ -175,6 +197,8 @@ static const R_CallMethodDef CallEntries[] = { {"_jaspSyntax_analysisOptionsFromJaspFile", (DL_FUNC) &_jaspSyntax_analysisOptionsFromJaspFile, 2}, {"_jaspSyntax_generateAnalysisWrapper", (DL_FUNC) &_jaspSyntax_generateAnalysisWrapper, 2}, {"_jaspSyntax_getVariableNames", (DL_FUNC) &_jaspSyntax_getVariableNames, 0}, + {"_jaspSyntax_columnEncoderContextNative", (DL_FUNC) &_jaspSyntax_columnEncoderContextNative, 0}, + {"_jaspSyntax_decodeColumnTextNative", (DL_FUNC) &_jaspSyntax_decodeColumnTextNative, 2}, {NULL, NULL, 0} }; diff --git a/src/syntaxfunctions.cpp b/src/syntaxfunctions.cpp index faa4d26..72748b0 100644 --- a/src/syntaxfunctions.cpp +++ b/src/syntaxfunctions.cpp @@ -19,6 +19,9 @@ #include using namespace Rcpp; +#include +#include + #include "dataframeimporter.h" #include "syntaxbridge_interface.h" #include "json/json.h" @@ -57,6 +60,35 @@ Json::Value parseBridgeJsonOrStop(const char * rawJson, const char * functionNam return parsedJson; } +std::string normalizeParameterString(std::string value) +{ + auto isNotSpace = [](unsigned char c) { return !std::isspace(c); }; + value.erase(value.begin(), std::find_if(value.begin(), value.end(), isNotSpace)); + value.erase(std::find_if(value.rbegin(), value.rend(), isNotSpace).base(), value.end()); + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { return std::tolower(c); }); + return value; +} + +bool verboseValueShowsNativeOutput(const std::string & value, bool & verbose) +{ + const std::string normalizedValue = normalizeParameterString(value); + if (normalizedValue == "all" || normalizedValue == "jasp" || normalizedValue == "true" || + normalizedValue == "yes" || normalizedValue == "on" || normalizedValue == "1") + { + verbose = true; + return true; + } + + if (normalizedValue == "analysis" || normalizedValue == "none" || normalizedValue == "false" || + normalizedValue == "no" || normalizedValue == "off" || normalizedValue == "0") + { + verbose = false; + return true; + } + + return false; +} + // [[Rcpp::export]] void cleanUp() { @@ -101,7 +133,7 @@ void clearNativeStateNative() } // [[Rcpp::export]] -bool setParameter(String name, SEXP value) +bool setParameterNative(String name, SEXP value) { std::string nameStr = name.get_cstring(); @@ -120,6 +152,26 @@ bool setParameter(String name, SEXP value) global_param_orderLabelsByValue = Rcpp::as(value); return true; } + else if (nameStr == "verbose") + { + if (Rcpp::is(value)) + { + syntaxBridgeSetVerbose(Rcpp::as(value)); + return true; + } + + if (TYPEOF(value) == STRSXP && Rf_length(value) > 0) + { + bool verbose = false; + if (!verboseValueShowsNativeOutput(Rcpp::as(value), verbose)) + Rcpp::stop("Unsupported verbose value. Use 'all', 'analysis', 'jasp', 'none', TRUE, or FALSE."); + + syntaxBridgeSetVerbose(verbose); + return true; + } + + return false; + } return false; } @@ -144,10 +196,22 @@ String loadQmlAndParseOptions(String moduleName, String analysisName, String qml analysisNameStr = analysisName.get_cstring(), moduleNameStr = moduleName.get_cstring(); + Json::Value status = parseBridgeJsonOrStop( + callBridgeOrStop("syntaxBridgeLoadQmlAndParseOptionsStatus", [&]() { + return syntaxBridgeLoadQmlAndParseOptionsStatus(moduleNameStr.c_str(), analysisNameStr.c_str(), qmlFileStr.c_str(), optionsStr.c_str(), versionStr.c_str(), preloadData); + }), + "syntaxBridgeLoadQmlAndParseOptionsStatus" + ); - return callBridgeOrStop("syntaxBridgeLoadQmlAndParseOptions", [&]() { - return syntaxBridgeLoadQmlAndParseOptions(moduleNameStr.c_str(), analysisNameStr.c_str(), qmlFileStr.c_str(), optionsStr.c_str(), versionStr.c_str(), preloadData); - }); + if (!status["ok"].asBool()) + { + std::string error = status.isMember("error") ? status["error"].asString() : "unknown error"; + Rcpp::stop("Error when parsing options: %s", error.c_str()); + } + + static std::string result; + result = status["options"].toStyledString(); + return result.c_str(); } // [[Rcpp::export]] @@ -338,5 +402,58 @@ Rcpp::List getVariableNames() return result; } +// [[Rcpp::export]] +String columnEncoderContextNative() +{ + return callBridgeOrStop("syntaxBridgeColumnEncoderContext", []() { + return syntaxBridgeColumnEncoderContext(); + }); +} + +// [[Rcpp::export]] +Rcpp::CharacterVector decodeColumnTextNative(Rcpp::CharacterVector values, String encoderContextJson) +{ + Json::Value input(Json::arrayValue); + for (R_xlen_t i = 0; i < values.size(); ++i) + { + Rcpp::String value = values[i]; + if (value == NA_STRING) + input.append(Json::Value()); + else + input.append(std::string(value.get_cstring())); + } + + const std::string inputJson = input.toStyledString(); + const std::string contextJson = std::string(encoderContextJson.get_cstring()); + Json::Value decoded = parseBridgeJsonOrStop( + callBridgeOrStop("syntaxBridgeDecodeColumnText", [&]() { + return syntaxBridgeDecodeColumnText(inputJson.c_str(), contextJson.c_str()); + }), + "syntaxBridgeDecodeColumnText" + ); + + if (decoded.isObject() && decoded.isMember("ok") && !decoded["ok"].asBool()) + { + std::string error = decoded.isMember("error") ? decoded["error"].asString() : "unknown error"; + Rcpp::stop("syntaxBridgeDecodeColumnText failed: %s", error.c_str()); + } + + if (!decoded.isArray()) + Rcpp::stop("syntaxBridgeDecodeColumnText returned a non-array JSON value."); + + Rcpp::CharacterVector result(decoded.size()); + for (Json::ArrayIndex i = 0; i < decoded.size(); ++i) + { + if (decoded[i].isNull()) + result[i] = NA_STRING; + else if (decoded[i].isString()) + result[i] = decoded[i].asCString(); + else + Rcpp::stop("syntaxBridgeDecodeColumnText returned a non-string value."); + } + + return result; +} + diff --git a/tests/testthat/test-dataset-helpers.R b/tests/testthat/test-dataset-helpers.R index 062d62e..3506360 100644 --- a/tests/testthat/test-dataset-helpers.R +++ b/tests/testthat/test-dataset-helpers.R @@ -53,7 +53,49 @@ localNamespaceBinding <- function(name, value, namespace) { } } +localNativeColumnTextDecoder <- function(mapping) { + localNamespaceBinding( + "decodeColumnText", + function(text, encoderContext = NULL) { + out <- text + tokens <- names(mapping) + tokens <- tokens[order(nchar(tokens), decreasing = TRUE)] + for (token in tokens) { + out <- gsub(token, unname(mapping[[token]]), out, fixed = TRUE) + } + out + }, + asNamespace("jaspSyntax") + ) +} + +localFailingColumnTextDecoder <- function(message = "native decoder unavailable") { + localNamespaceBinding( + "decodeColumnText", + function(text, encoderContext = NULL) { + stop(message, call. = FALSE) + }, + asNamespace("jaspSyntax") + ) +} + test_that("decodeColumnNames delegates to the native bridge decoder", { + restoreDecoder <- localNativeColumnTextDecoder( + c( + JaspColumn_1_Encoded = "raw score", + JaspColumn_2_Encoded = "group" + ) + ) + on.exit(restoreDecoder(), add = TRUE) + + expect_equal( + jaspSyntax::decodeColumnNames(c("JaspColumn_1_Encoded", "JaspColumn_2_Encoded")), + c("raw score", "group") + ) +}) + +test_that("decodeColumnNames errors when native decoding is unavailable", { + restoreNative <- localFailingColumnTextDecoder() restoreDecoder <- localGlobalBinding( ".decodeColNamesStrict", function(columnName) { @@ -63,48 +105,130 @@ test_that("decodeColumnNames delegates to the native bridge decoder", { )[[columnName]] } ) + on.exit(restoreNative(), add = TRUE) on.exit(restoreDecoder(), add = TRUE) - expect_equal( + expect_error( jaspSyntax::decodeColumnNames(c("JaspColumn_1_Encoded", "JaspColumn_2_Encoded")), - c("raw score", "group") - ) - expect_equal( - jaspSyntax::columnMapping(c("JaspColumn_1_Encoded", "JaspColumn_2_Encoded")), - c(JaspColumn_1_Encoded = "raw score", JaspColumn_2_Encoded = "group") + "native decoder unavailable", + fixed = TRUE ) }) -test_that("decodeColumnNames can fall back or fail when the decoder is unavailable", { - restoreDecoder <- localGlobalAbsent(".decodeColNamesStrict") +test_that("decodeColumnNames fails when the decoder is unavailable", { + restoreDecoder <- localFailingColumnTextDecoder() on.exit(restoreDecoder(), add = TRUE) - expect_equal( + expect_error( jaspSyntax::decodeColumnNames(c("plain", "JaspColumn_1_Encoded")), - c("plain", "JaspColumn_1_Encoded") + "native decoder unavailable", + fixed = TRUE ) expect_error( jaspSyntax::decodeColumnNames("JaspColumn_1_Encoded", strict = TRUE), - "did not expose `.decodeColNamesStrict`", + "native decoder unavailable", fixed = TRUE ) +}) + +test_that("decodeColumnNames strict flag is compatibility-only", { + restoreDecoder <- localNativeColumnTextDecoder(c(JaspColumn_1_Encoded = "score")) + on.exit(restoreDecoder(), add = TRUE) + + expect_equal( + jaspSyntax::decodeColumnNames(c("plain", "JaspColumn_1_Encoded"), strict = FALSE), + c("plain", "score") + ) + expect_equal( + jaspSyntax::decodeColumnNames(c("plain", "JaspColumn_1_Encoded"), strict = TRUE), + c("plain", "score") + ) +}) + +test_that("decodeColumnNames fails when native decoding leaves encoded names", { + restoreDecoder <- localNativeColumnTextDecoder(stats::setNames(character(), character())) + on.exit(restoreDecoder(), add = TRUE) + + expect_error( + jaspSyntax::decodeColumnNames("JaspColumn_1_Encoded"), + "left encoded column name", + fixed = TRUE + ) +}) + +test_that("empty column encoder contexts do not fall through to live native state", { + observedContext <- NULL + restoreNative <- localNamespaceBinding( + "decodeColumnTextNative", + function(values, encoderContextJson) { + observedContext <<- jsonlite::fromJSON(encoderContextJson, simplifyVector = FALSE) + values + }, + asNamespace("jaspSyntax") + ) + on.exit(restoreNative(), add = TRUE) + + context <- jaspSyntax:::.newColumnEncoderContext() + + expect_equal( + jaspSyntax::decodeColumnText("JaspColumn_1_Encoded", context), + "JaspColumn_1_Encoded" + ) + expect_equal(observedContext$columns, list()) +}) + +test_that("decodeColumnText does not use R mapping replacement when native decoding fails", { + restoreNative <- localNamespaceBinding( + "decodeColumnTextNative", + function(values, encoderContextJson) { + stop("native failure", call. = FALSE) + }, + asNamespace("jaspSyntax") + ) + on.exit(restoreNative(), add = TRUE) + + context <- jaspSyntax:::.newColumnEncoderContext( + columns = list(list(name = "score", type = "scale")) + ) expect_error( - jaspSyntax::columnMapping("JaspColumn_1_Encoded", strict = TRUE), - "did not expose `.decodeColNamesStrict`", + jaspSyntax::decodeColumnText("JaspColumn_1_Encoded", context), + "native failure", fixed = TRUE ) }) +test_that("decodeColumnText dispatches extra option tokens to the native decoder", { + observed <- NULL + restoreNative <- localNamespaceBinding( + "decodeColumnTextNative", + function(values, encoderContextJson) { + observed <<- values + gsub("JaspExtraOptions_0_Encoded", "Factor A", values, fixed = TRUE) + }, + asNamespace("jaspSyntax") + ) + on.exit(restoreNative(), add = TRUE) + + expect_equal( + jaspSyntax::decodeColumnText("JaspExtraOptions_0_Encoded"), + "Factor A" + ) + expect_equal(observed, "JaspExtraOptions_0_Encoded") +}) + test_that("decodeColumnNames does not send raw names to the strict native decoder", { - decoderCalls <- character(0) - restoreDecoder <- localGlobalBinding( - ".decodeColNamesStrict", - function(columnName) { - decoderCalls <<- c(decoderCalls, columnName) - c(JaspColumn_1_Encoded = "score")[[columnName]] - } + observed <- character(0) + restoreNative <- localNamespaceBinding( + "decodeColumnText", + function(text, encoderContext = NULL) { + observed <<- c(observed, text) + out <- text + out[text == "JaspColumn_1_Encoded"] <- "score" + out + }, + asNamespace("jaspSyntax") ) - on.exit(restoreDecoder(), add = TRUE) + on.exit(restoreNative(), add = TRUE) expect_equal( jaspSyntax::decodeColumnNames( @@ -113,9 +237,8 @@ test_that("decodeColumnNames does not send raw names to the strict native decode ), c("score", "score.scale", "score") ) - expect_equal(decoderCalls, "JaspColumn_1_Encoded") + expect_equal(observed, "JaspColumn_1_Encoded") - restoreDecoder() expect_equal( jaspSyntax::decodeColumnNames(c("score", "score.scale"), strict = TRUE), c("score", "score.scale") @@ -123,7 +246,7 @@ test_that("decodeColumnNames does not send raw names to the strict native decode }) test_that("state readers fail loudly when decode is requested without decoder support", { - restoreDecoder <- localGlobalAbsent(".decodeColNamesStrict") + restoreDecoder <- localFailingColumnTextDecoder() restoreLoaded <- localGlobalBinding( ".readFullDatasetToEnd", function() { @@ -150,17 +273,17 @@ test_that("state readers fail loudly when decode is requested without decoder su expect_error( jaspSyntax::readLoadedDataset(decode = TRUE), - "did not expose `.decodeColNamesStrict`", + "native decoder unavailable", fixed = TRUE ) expect_error( jaspSyntax::readRequestedDataset(decode = TRUE), - "did not expose `.decodeColNamesStrict`", + "native decoder unavailable", fixed = TRUE ) expect_error( jaspSyntax::readDatasetHeader(decode = TRUE), - "did not expose `.decodeColNamesStrict`", + "native decoder unavailable", fixed = TRUE ) @@ -184,15 +307,17 @@ test_that("readLoadedDataset reads, decodes, and normalizes bridge data", { ) restoreDecoder <- localGlobalBinding( ".decodeColNamesStrict", - function(columnName) { - c( - JaspColumn_1_Encoded = "id", - JaspColumn_2_Encoded = "condition" - )[[columnName]] - } + function(columnName) stop("legacy decoder must not be used", call. = FALSE) + ) + restoreNative <- localNativeColumnTextDecoder( + c( + JaspColumn_1_Encoded = "id", + JaspColumn_2_Encoded = "condition" + ) ) on.exit(restoreDataset(), add = TRUE) on.exit(restoreDecoder(), add = TRUE) + on.exit(restoreNative(), add = TRUE) dataset <- jaspSyntax::readLoadedDataset() @@ -223,13 +348,13 @@ test_that("factor normalization preserves numeric-looking category labels", { }) test_that("decodeAnalysisResults decodes native column names and factor value tokens", { - restoreDecoder <- localGlobalBinding( - ".decodeColNamesStrict", - function(columnName) { - c(JaspColumn_1_Encoded = "group")[[columnName]] - } + restoreDecoder <- localNativeColumnTextDecoder( + c(JaspColumn_1_Encoded = "group") ) on.exit(restoreDecoder(), add = TRUE) + encoderContext <- jaspSyntax:::.newColumnEncoderContext( + columns = list(list(name = "group", type = "nominal")) + ) requestedDataset <- data.frame( JaspColumn_1_Encoded = factor(c("control", "treatment")), @@ -248,7 +373,11 @@ test_that("decodeAnalysisResults decodes native column names and factor value to ) ) - decoded <- jaspSyntax::decodeAnalysisResults(results, requestedDataset = requestedDataset) + decoded <- jaspSyntax::decodeAnalysisResults( + results, + requestedDataset = requestedDataset, + columnEncoderContext = encoderContext + ) firstRow <- decoded$results$table$data[[1L]] expect_equal(names(firstRow), c("group", "label")) @@ -256,14 +385,26 @@ test_that("decodeAnalysisResults decodes native column names and factor value to expect_equal(firstRow$label, "group") }) -test_that("decodeAnalysisResults can use captured column mapping without native decoder", { - restoreDecoder <- localGlobalBinding( - ".decodeColNamesStrict", - function(columnName) { - stop("native decoder should not be called") - } +test_that("decodeAnalysisResults passes captured column encoder context to the native decoder", { + observedContext <- NULL + restoreDecoder <- localNamespaceBinding( + "decodeColumnText", + function(text, encoderContext = NULL) { + observedContext <<- encoderContext + out <- text + out <- gsub("JaspColumn_1_Encoded", "group", out, fixed = TRUE) + out <- gsub("jaspColumn2", "phase", out, fixed = TRUE) + out + }, + asNamespace("jaspSyntax") ) on.exit(restoreDecoder(), add = TRUE) + encoderContext <- jaspSyntax:::.newColumnEncoderContext( + columns = list( + list(name = "group", type = "nominal"), + list(name = "phase", type = "nominal") + ) + ) requestedDataset <- data.frame( JaspColumn_1_Encoded = factor(c("control", "treatment")), @@ -275,7 +416,10 @@ test_that("decodeAnalysisResults can use captured column mapping without native data = list( list( JaspColumn_1_Encoded = "2", - Variable = "JaspColumn_1_Encoded" + Variable = "JaspColumn_1_Encoded", + Interaction = "JaspColumn_1_Encoded:jaspColumn2", + Note = "The following variables are used: 'JaspColumn_1_Encoded', 'jaspColumn2'.", + Unmatched = "jaspColumn10" ) ) ) @@ -285,23 +429,27 @@ test_that("decodeAnalysisResults can use captured column mapping without native decoded <- jaspSyntax::decodeAnalysisResults( results, requestedDataset = requestedDataset, - columnMapping = c(JaspColumn_1_Encoded = "group") + columnEncoderContext = encoderContext ) firstRow <- decoded$results$table$data[[1L]] - expect_equal(names(firstRow), c("group", "Variable")) + expect_equal(names(firstRow), c("group", "Variable", "Interaction", "Note", "Unmatched")) expect_equal(firstRow$group, "treatment") expect_equal(firstRow$Variable, "group") + expect_equal(firstRow$Interaction, "group:phase") + expect_equal(firstRow$Note, "The following variables are used: 'group', 'phase'.") + expect_equal(firstRow$Unmatched, "jaspColumn10") + expect_s3_class(observedContext, "jaspSyntaxColumnEncoderContext") }) test_that("decodeAnalysisResults maps factor values with decoded requested datasets", { - restoreDecoder <- localGlobalBinding( - ".decodeColNamesStrict", - function(columnName) { - stop("native decoder should not be called") - } + restoreDecoder <- localNativeColumnTextDecoder( + c(JaspColumn_1_Encoded = "group") ) on.exit(restoreDecoder(), add = TRUE) + encoderContext <- jaspSyntax:::.newColumnEncoderContext( + columns = list(list(name = "group", type = "nominal")) + ) requestedDataset <- data.frame( group = factor(c("control", "treatment")), @@ -320,7 +468,7 @@ test_that("decodeAnalysisResults maps factor values with decoded requested datas decoded <- jaspSyntax::decodeAnalysisResults( results, requestedDataset = requestedDataset, - columnMapping = c(JaspColumn_1_Encoded = "group") + columnEncoderContext = encoderContext ) firstRow <- decoded$results$table$data[[1L]] @@ -340,12 +488,14 @@ test_that("readRequestedDataset exposes requested native dataset state", { ) restoreDecoder <- localGlobalBinding( ".decodeColNamesStrict", - function(columnName) { - c(JaspColumn_1_Encoded = "requested")[[columnName]] - } + function(columnName) stop("legacy decoder must not be used", call. = FALSE) + ) + restoreNative <- localNativeColumnTextDecoder( + c(JaspColumn_1_Encoded = "requested") ) on.exit(restoreDataset(), add = TRUE) on.exit(restoreDecoder(), add = TRUE) + on.exit(restoreNative(), add = TRUE) dataset <- jaspSyntax::readRequestedDataset() @@ -363,15 +513,17 @@ test_that("readDatasetHeader decodes native header names", { ) restoreDecoder <- localGlobalBinding( ".decodeColNamesStrict", - function(columnName) { - c( - JaspColumn_1_Encoded = "score", - JaspColumn_2_Encoded = "group" - )[[columnName]] - } + function(columnName) stop("legacy decoder must not be used", call. = FALSE) + ) + restoreNative <- localNativeColumnTextDecoder( + c( + JaspColumn_1_Encoded = "score", + JaspColumn_2_Encoded = "group" + ) ) on.exit(restoreNames(), add = TRUE) on.exit(restoreDecoder(), add = TRUE) + on.exit(restoreNative(), add = TRUE) header <- jaspSyntax::readDatasetHeader() @@ -437,12 +589,24 @@ test_that("loadAnalysisDataset returns loaded and requested state from native he ) restoreDecoder <- localGlobalBinding( ".decodeColNamesStrict", - function(columnName) { - c( - JaspColumn_1_Encoded = "score", - JaspColumn_2_Encoded = "group" - )[[columnName]] - } + function(columnName) stop("legacy decoder must not be used", call. = FALSE) + ) + restoreNative <- localNativeColumnTextDecoder( + c( + JaspColumn_1_Encoded = "score", + JaspColumn_2_Encoded = "group" + ) + ) + encoderContext <- jaspSyntax:::.newColumnEncoderContext( + columns = list( + list(name = "score", type = "scale"), + list(name = "group", type = "nominal") + ) + ) + restoreContext <- localNamespaceBinding( + "columnEncoderContext", + function() encoderContext, + asNamespace("jaspSyntax") ) on.exit(restoreClear(), add = TRUE) on.exit(restoreLoad(), add = TRUE) @@ -450,6 +614,8 @@ test_that("loadAnalysisDataset returns loaded and requested state from native he on.exit(restoreLoaded(), add = TRUE) on.exit(restoreRequested(), add = TRUE) on.exit(restoreDecoder(), add = TRUE) + on.exit(restoreNative(), add = TRUE) + on.exit(restoreContext(), add = TRUE) rawDataset <- data.frame(score = c(1, 2), group = c("a", "b")) savedOptions <- list(variables = list(value = "score", types = "scale")) @@ -476,7 +642,8 @@ test_that("loadAnalysisDataset returns loaded and requested state from native he expect_s3_class(state$resultDecodingDataset$score, "factor") expect_equal(levels(state$resultDecodingDataset$score), c("control", "treatment")) expect_equal(state$runtimeOptions$variables, "JaspColumn_1_Encoded") - expect_equal(state$columnMapping, c(JaspColumn_1_Encoded = "score", JaspColumn_2_Encoded = "group")) + expect_s3_class(state$columnEncoderContext, "jaspSyntaxColumnEncoderContext") + expect_equal(state$columnEncoderContext$columns, encoderContext$columns) expect_s3_class(state, "jaspSyntax_analysis_dataset_state") }) @@ -525,7 +692,10 @@ test_that("loadAnalysisDataset reuses native .jasp source when provenance is int ) restoreDecoder <- localGlobalBinding( ".decodeColNamesStrict", - function(columnName) c(JaspColumn_1_Encoded = "score")[[columnName]] + function(columnName) stop("legacy decoder must not be used", call. = FALSE) + ) + restoreNative <- localNativeColumnTextDecoder( + c(JaspColumn_1_Encoded = "score") ) on.exit(restoreClear(), add = TRUE) on.exit(restoreLoadDataFrame(), add = TRUE) @@ -534,6 +704,7 @@ test_that("loadAnalysisDataset reuses native .jasp source when provenance is int on.exit(restoreLoaded(), add = TRUE) on.exit(restoreRequested(), add = TRUE) on.exit(restoreDecoder(), add = TRUE) + on.exit(restoreNative(), add = TRUE) dataset <- jaspSyntax:::.attachJaspDatasetSource( data.frame(score = 1), @@ -692,6 +863,8 @@ test_that("subprocess package loading distinguishes source checkouts from instal "jaspSyntax subprocess PATH head", fixed = TRUE ) + expect_identical(environment(jaspSyntax:::.bridgeSubprocessPackageLoader()), baseenv()) + expect_identical(environment(jaspSyntax:::.bridgeSubprocessRunner()), baseenv()) descriptionCandidates <- c( file.path(getwd(), "DESCRIPTION"), diff --git a/tests/testthat/test-desktop-jasp-contract.R b/tests/testthat/test-desktop-jasp-contract.R index b0aa2f9..a7d26ce 100644 --- a/tests/testthat/test-desktop-jasp-contract.R +++ b/tests/testthat/test-desktop-jasp-contract.R @@ -127,8 +127,12 @@ test_that("native and R bridge exports keep the expected consumer formals", { expect_named(formals(jaspSyntax::readRequestedDataset), c("decode", "normalize")) expect_named(formals(jaspSyntax::readDatasetHeader), "decode") expect_named(formals(jaspSyntax::decodeColumnNames), c("columnNames", "strict")) - expect_named(formals(jaspSyntax::decodeAnalysisResults), c("results", "requestedDataset", "columnMapping")) - expect_named(formals(jaspSyntax::columnMapping), c("encodedColumnNames", "strict")) + expect_true(is.null(formals(jaspSyntax::columnEncoderContext)) || + identical(names(formals(jaspSyntax::columnEncoderContext)), character(0))) + expect_named(formals(jaspSyntax::decodeColumnText), c("text", "encoderContext")) + expect_named(formals(jaspSyntax::decodeAnalysisResults), c("results", "requestedDataset", "columnEncoderContext")) + expect_false("columnMapping" %in% getNamespaceExports("jaspSyntax")) + expect_false("columnDecoderSnapshot" %in% getNamespaceExports("jaspSyntax")) expect_named( formals(jaspSyntax::readAnalysisOptionsFromJaspFile), c("jaspFilePath", "modulePath", "runtime", "includeMeta", "includeTypeOptions", "isolated") @@ -138,3 +142,25 @@ test_that("native and R bridge exports keep the expected consumer formals", { expect_true(formals(jaspSyntax::readAnalysisOptionsFromJaspFile)$includeTypeOptions) expect_true(formals(jaspSyntax::readAnalysisOptionsFromJaspFile)$isolated) }) + +test_that("verbose parameter sets the wrapped-analysis verbosity default", { + oldOptions <- options(jaspSyntax.verbose = NULL) + on.exit(do.call(options, oldOptions), add = TRUE) + + expect_true(jaspSyntax::setParameter("verbose", FALSE)) + expect_identical(getOption("jaspSyntax.verbose"), "analysis") + + expect_true(jaspSyntax::setParameter("verbose", TRUE)) + expect_identical(getOption("jaspSyntax.verbose"), "all") + + expect_true(jaspSyntax::setParameter("verbose", "jasp")) + expect_identical(getOption("jaspSyntax.verbose"), "jasp") + + expect_true(jaspSyntax::setParameter("verbose", "none")) + expect_identical(getOption("jaspSyntax.verbose"), "none") + + expect_error( + jaspSyntax::setParameter("verbose", "loud"), + "`verbose` must be one of" + ) +}) diff --git a/tests/testthat/test-native-column-context.R b/tests/testthat/test-native-column-context.R new file mode 100644 index 0000000..2e55336 --- /dev/null +++ b/tests/testthat/test-native-column-context.R @@ -0,0 +1,116 @@ +context("native column encoder context") + +localNativeColumnBridge <- function() { + tryCatch( + { + jaspSyntax::clearNativeState() + jaspSyntax::setParameter("verbose", "none") + invisible(TRUE) + }, + error = function(e) { + testthat::skip(paste0("Native SyntaxInterface bridge unavailable: ", conditionMessage(e))) + } + ) +} + +contextColumnNames <- function(context) { + vapply(context$columns, function(column) column$name, character(1L)) +} + +contextDecodeExpectation <- function(context) { + columnNames <- contextColumnNames(context) + c( + columnNames[[1L]], + columnNames[[2L]], + paste0("model uses ", columnNames[[1L]], " and ", columnNames[[2L]]) + ) +} + +loadContextDataset <- function(dataset) { + jaspSyntax::clearNativeState() + jaspSyntax::loadDataSet(dataset) + jaspSyntax::columnEncoderContext() +} + +test_that("native decoder contexts can be interleaved without mutating live state", { + localNativeColumnBridge() + on.exit(jaspSyntax::clearNativeState(), add = TRUE) + + datasets <- list( + alpha = data.frame( + alpha_group = c("a", "b", "a"), + alpha_score = c(1.1, 2.2, 3.3), + check.names = FALSE + ), + beta = data.frame( + beta_group = c("x", "y", "x"), + beta_score = c(10.5, 11.5, 12.5), + check.names = FALSE + ), + gamma = data.frame( + gamma_group = c("left", "right", "left"), + gamma_score = c(-1.25, 0.5, 1.75), + check.names = FALSE + ) + ) + + contexts <- lapply(datasets, loadContextDataset) + for (datasetName in names(datasets)) { + expect_equal(sort(contextColumnNames(contexts[[datasetName]])), names(datasets[[datasetName]])) + } + + jaspSyntax::clearNativeState() + jaspSyntax::loadDataSet(datasets$alpha) + liveContext <- jaspSyntax::columnEncoderContext() + + tokens <- c( + "JaspColumn_0_Encoded", + "JaspColumn_3_Encoded", + "model uses JaspColumn_0_Encoded and JaspColumn_3_Encoded" + ) + liveExpected <- contextDecodeExpectation(liveContext) + expect_equal(jaspSyntax::decodeColumnText(tokens), liveExpected) + + interleavedContexts <- contexts[c("beta", "gamma", "alpha", "beta", "alpha", "gamma")] + for (context in interleavedContexts) { + expect_equal( + jaspSyntax::decodeColumnText(tokens, context), + contextDecodeExpectation(context) + ) + expect_equal(jaspSyntax::decodeColumnText(tokens), liveExpected) + } + + restoredContext <- jaspSyntax::columnEncoderContext() + expect_equal(restoredContext$columns, liveContext$columns) + expect_equal(restoredContext$extra, liveContext$extra) + expect_equal(jaspSyntax::decodeColumnText(tokens), liveExpected) +}) + +test_that("native decoder C API reports malformed contexts without corrupting live state", { + localNativeColumnBridge() + on.exit(jaspSyntax::clearNativeState(), add = TRUE) + + dataset <- data.frame( + api_group = c("control", "treatment"), + api_score = c(1.25, 2.5), + check.names = FALSE + ) + jaspSyntax::loadDataSet(dataset) + + rawContext <- jaspSyntax:::columnEncoderContextNative() + parsedContext <- jsonlite::fromJSON(rawContext, simplifyVector = FALSE) + expect_equal(parsedContext$version, 1L) + expect_equal(sort(vapply(parsedContext$columns, `[[`, character(1L), "name")), names(dataset)) + expect_equal(parsedContext$extra, list()) + + tokens <- c("JaspColumn_0_Encoded", "JaspColumn_3_Encoded") + liveBefore <- jaspSyntax::decodeColumnText(tokens) + + expect_error( + jaspSyntax:::decodeColumnTextNative(tokens, "{not valid context json"), + "Could not parse column encoder context JSON", + fixed = TRUE + ) + + expect_equal(jaspSyntax::decodeColumnText(tokens), liveBefore) +})