From 533b4a8cbe51a3afe51027d9cb89b9beb2a7aba4 Mon Sep 17 00:00:00 2001 From: Gauarv Chaudhary Date: Sat, 10 Jan 2026 13:10:21 +0530 Subject: [PATCH 1/5] test: Add jsonlite validation for migration preparation (#193) Add comprehensive validation tests for jsonlite compatibility before migrating from RJSONIO. These tests verify jsonlite with auto_unbox=TRUE can handle all animint2 data structures. Tests validate: - Single value encoding without array wrapping - Vector arrays preservation - Nested geom structures (plot.json format) - Layout data with boolean arrays - Axis, grid, selector, and panel data - Complete export.data round-trip - Valid JSON output for JavaScript parsing - Numeric precision preservation All 44 validation tests pass, confirming jsonlite is ready for migration. Actual code migration will follow in next commit. Also adds jsonlite to Suggests in DESCRIPTION for testing. Part of #193 - RJSONIO deprecation migration --- DESCRIPTION | 1 + tests/testthat/test-compiler-json-migration.R | 209 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 tests/testthat/test-compiler-json-migration.R diff --git a/DESCRIPTION b/DESCRIPTION index cdd8e0a3..9fd0662e 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -88,6 +88,7 @@ Imports: data.table (>= 1.9.8), methods Suggests: + jsonlite, gert, gitcreds, gh, sp, gistr (>= 0.2), diff --git a/tests/testthat/test-compiler-json-migration.R b/tests/testthat/test-compiler-json-migration.R new file mode 100644 index 00000000..daefbab7 --- /dev/null +++ b/tests/testthat/test-compiler-json-migration.R @@ -0,0 +1,209 @@ +acontext("JSON migration validation for issue #193") +## This test validates jsonlite compatibility for RJSONIO migration. +## See https://github.com/animint/animint2/issues/193 +## RJSONIO is no longer maintained on CRAN, so we migrate to jsonlite. +## The key requirement: auto_unbox=TRUE makes jsonlite match RJSONIO behavior. +test_that("jsonlite encodes single values without array wrapping when auto_unbox=TRUE", { + data <- list(geom="point", size=3, enabled=TRUE) + json_str <- jsonlite::toJSON(data, auto_unbox=TRUE) + expect_false(grepl('\\["point"\\]', json_str)) + expect_false(grepl('\\[3\\]', json_str)) + expect_true(grepl('"geom"\\s*:\\s*"point"', json_str)) + expect_true(grepl('"size"\\s*:\\s*3', json_str)) +}) +test_that("jsonlite preserves vector arrays correctly", { + data <- list(x=c(1,2,3), labels=c("a","b","c")) + json_str <- jsonlite::toJSON(data, auto_unbox=TRUE) + parsed <- jsonlite::fromJSON(json_str, simplifyVector=FALSE) + expect_equal(length(parsed$x), 3) + expect_equal(length(parsed$labels), 3) + expect_equal(parsed$x[[1]], 1) + expect_equal(parsed$labels[[2]], "b") +}) +test_that("jsonlite handles nested list structures like plot.json geoms", { + geom_data <- list( + geom1_point_plot=list( + geom="point", + classed="geom1_point_plot", + aes=list(x="x", y="y"), + params=list(na.rm=FALSE, size=3), + chunks=1, + total=1 + ) + ) + json_str <- jsonlite::toJSON(geom_data, auto_unbox=TRUE) + parsed <- jsonlite::fromJSON(json_str, simplifyVector=FALSE) + expect_equal(parsed$geom1_point_plot$geom, "point") + expect_equal(parsed$geom1_point_plot$aes$x, "x") + expect_equal(parsed$geom1_point_plot$params$size, 3) + expect_equal(parsed$geom1_point_plot$chunks, 1) +}) +test_that("jsonlite handles plot layout data with boolean arrays", { + layout_data <- list( + PANEL=c("1","2","3","4"), + ROW=c(1,1,2,2), + COL=c(1,2,1,2), + AXIS_X=c(FALSE,FALSE,TRUE,TRUE), + AXIS_Y=c(TRUE,FALSE,TRUE,FALSE) + ) + json_str <- jsonlite::toJSON(layout_data, auto_unbox=TRUE) + parsed <- jsonlite::fromJSON(json_str, simplifyVector=FALSE) + expect_equal(length(parsed$PANEL), 4) + expect_equal(parsed$AXIS_X[[3]], TRUE) + expect_equal(parsed$AXIS_Y[[2]], FALSE) +}) +test_that("jsonlite handles axis tick data arrays", { + axis_data <- list( + x=c(2.5, 5, 7.5, 10), + xlab=c("2.5", "5.0", "7.5", "10.0"), + xrange=c(0.55, 10.45), + xline=TRUE, + xticks=TRUE + ) + json_str <- jsonlite::toJSON(axis_data, auto_unbox=TRUE) + parsed <- jsonlite::fromJSON(json_str, simplifyVector=FALSE) + expect_equal(length(parsed$x), 4) + expect_equal(parsed$xrange[[1]], 0.55) + expect_equal(parsed$xline, TRUE) +}) +test_that("jsonlite handles nested grid location arrays", { + grid_data <- list( + grid_major=list( + colour="#FFFFFF", + size=0.5, + loc=list( + x=list(c(2.5,5,7.5,10), c(2.5,5,7.5,10)), + y=list(c(2.5,5,7.5,10), c(2.5,5,7.5,10)) + ) + ) + ) + json_str <- jsonlite::toJSON(grid_data, auto_unbox=TRUE) + parsed <- jsonlite::fromJSON(json_str, simplifyVector=FALSE) + expect_equal(parsed$grid_major$colour, "#FFFFFF") + expect_equal(length(parsed$grid_major$loc$x), 2) + expect_equal(length(parsed$grid_major$loc$x[[1]]), 4) +}) +test_that("jsonlite handles selector structures for interactivity", { + selector_data <- list( + selectors=list( + year=list(selected="2000", type="single") + ), + first=list(year="2000"), + time=list(ms=2000, variable="year"), + duration=list(year=500) + ) + json_str <- jsonlite::toJSON(selector_data, auto_unbox=TRUE) + parsed <- jsonlite::fromJSON(json_str, simplifyVector=FALSE) + expect_equal(parsed$selectors$year$selected, "2000") + expect_equal(parsed$time$ms, 2000) + expect_equal(parsed$duration$year, 500) +}) +test_that("jsonlite handles empty lists and structures", { + data <- list(legend=list(), panel_border=list(), selectors=list()) + json_str <- jsonlite::toJSON(data, auto_unbox=TRUE) + parsed <- jsonlite::fromJSON(json_str, simplifyVector=FALSE) + expect_true("legend" %in% names(parsed)) + expect_true("panel_border" %in% names(parsed)) + expect_equal(length(parsed$legend), 0) +}) +test_that("jsonlite handles strip and facet data", { + strip_data <- list( + strips=list( + top=c("A","B","C","D"), + right=c(""), + n=list(top=2, right=0) + ) + ) + json_str <- jsonlite::toJSON(strip_data, auto_unbox=TRUE) + parsed <- jsonlite::fromJSON(json_str, simplifyVector=FALSE) + expect_equal(length(parsed$strips$top), 4) + expect_equal(parsed$strips$n$top, 2) +}) +test_that("jsonlite handles panel styling parameters", { + style_data <- list( + panel_background=list( + fill="#EBEBEB", + colour="transparent", + size=0.5, + linetype=1 + ) + ) + json_str <- jsonlite::toJSON(style_data, auto_unbox=TRUE) + parsed <- jsonlite::fromJSON(json_str, simplifyVector=FALSE) + expect_equal(parsed$panel_background$fill, "#EBEBEB") + expect_equal(parsed$panel_background$size, 0.5) +}) +test_that("jsonlite handles chunk_info metadata", { + chunk_data <- list( + chunk_info=list( + "geom1_point_chunk1.tsv"=list(bytes=258, rows=40) + ) + ) + json_str <- jsonlite::toJSON(chunk_data, auto_unbox=TRUE) + parsed <- jsonlite::fromJSON(json_str, simplifyVector=FALSE) + tsv_info <- parsed$chunk_info[["geom1_point_chunk1.tsv"]] + expect_equal(tsv_info$bytes, 258) + expect_equal(tsv_info$rows, 40) +}) +test_that("jsonlite round-trip preserves complete export.data structure", { + ## Mimics the export.data structure from R/z_animint.R line 631-639 + export_data <- list( + geoms=list( + geom1_point_scatter=list( + geom="point", + classed="geom1_point_scatter", + aes=list(x="x", y="y"), + params=list(na.rm=FALSE, size=3), + types=list(x="numeric", y="numeric"), + chunk_order=list(), + nest_order=c("PANEL"), + subset_order=c("PANEL"), + chunks=1, + total=1 + ) + ), + time=list(ms=2000, variable="year"), + duration=list(year=500), + selectors=list(year=list(selected="2000", type="single")), + plots=list( + scatter=list( + panel_margin_lines=0.25, + legend=list(), + xtitle="X Axis", + ytitle="Y Axis", + title="Test Plot", + options=list(width=600, height=400), + geoms=c("geom1_point_scatter") + ) + ) + ) + json_str <- jsonlite::toJSON(export_data, auto_unbox=TRUE) + parsed <- jsonlite::fromJSON(json_str, simplifyVector=FALSE) + expect_equal(parsed$geoms$geom1_point_scatter$geom, "point") + expect_equal(parsed$plots$scatter$title, "Test Plot") + expect_equal(parsed$time$ms, 2000) + expect_equal(parsed$selectors$year$selected, "2000") + expect_equal(parsed$plots$scatter$options$width, 600) +}) +test_that("jsonlite output is valid JSON parseable by JavaScript", { + ## This test ensures the JSON string format is valid + data <- list( + plot="scatter", + data=list(x=c(1,2,3), y=c(4,5,6)), + mapping=list(x="x", y="y") + ) + json_str <- jsonlite::toJSON(data, auto_unbox=TRUE) + ## Valid JSON should start with { and end with } + expect_true(grepl("^\\s*\\{", json_str)) + expect_true(grepl("\\}\\s*$", json_str)) + ## Should not have R-specific artifacts + expect_false(grepl("NA", json_str)) + expect_false(grepl("NULL", json_str)) +}) +test_that("jsonlite handles numeric precision for axis ranges", { + data <- list(xrange=c(0.55, 10.45), yrange=c(-3.14159, 2.71828)) + json_str <- jsonlite::toJSON(data, auto_unbox=TRUE) + parsed <- jsonlite::fromJSON(json_str, simplifyVector=FALSE) + expect_equal(parsed$xrange[[1]], 0.55, tolerance=1e-10) + expect_equal(parsed$yrange[[1]], -3.14159, tolerance=1e-5) +}) From e2b41bdf8d3fe824504736621edc4588fb0cea39 Mon Sep 17 00:00:00 2001 From: Gauarv Chaudhary Date: Sat, 10 Jan 2026 15:33:31 +0530 Subject: [PATCH 2/5] refactor: Migrate from RJSONIO to jsonlite (#193) Replace RJSONIO with jsonlite in DESCRIPTION, NAMESPACE, and all code. Use auto_unbox=TRUE and force=TRUE for toJSON, simplifyVector=FALSE for fromJSON. All tests pass. Closes #193 --- .github/workflows/atime.yaml | 2 ++ DESCRIPTION | 3 +-- NAMESPACE | 2 +- R/z_animint.R | 17 +++++++++++++++-- R/z_pages.R | 2 +- inst/examples/gps.R | 2 +- .../test-renderer3-chunk-NA-separate-lines.R | 6 +++--- 7 files changed, 24 insertions(+), 10 deletions(-) diff --git a/.github/workflows/atime.yaml b/.github/workflows/atime.yaml index 743886fb..7743c701 100644 --- a/.github/workflows/atime.yaml +++ b/.github/workflows/atime.yaml @@ -21,6 +21,8 @@ jobs: - uses: actions/checkout@v3 - uses: r-lib/actions/setup-r@v2 - uses: r-lib/actions/setup-r-dependencies@v2 + - name: install RJSONIO for testing old package versions + run: Rscript -e "install.packages('RJSONIO', repos='https://cloud.r-project.org')" - name: install package run: R CMD INSTALL . - uses: Anirban166/Autocomment-atime-results@v1.4.3 \ No newline at end of file diff --git a/DESCRIPTION b/DESCRIPTION index 9fd0662e..2e71a75e 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -76,7 +76,7 @@ Depends: Imports: servr, digest, - RJSONIO, + jsonlite, grid, gtable (>= 0.1.1), MASS, @@ -88,7 +88,6 @@ Imports: data.table (>= 1.9.8), methods Suggests: - jsonlite, gert, gitcreds, gh, sp, gistr (>= 0.2), diff --git a/NAMESPACE b/NAMESPACE index 72f51e80..fea0f31b 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -503,7 +503,7 @@ export(xlim) export(ylab) export(ylim) export(zeroGrob) -import(RJSONIO) +import(jsonlite) import(data.table) import(grid) import(gtable) diff --git a/R/z_animint.R b/R/z_animint.R index 3beb7dd7..2f8dde06 100644 --- a/R/z_animint.R +++ b/R/z_animint.R @@ -240,7 +240,7 @@ storeLayer <- function(meta, g, g.data.varied){ #' @param chromote_height height of chromote window in pixels, default 2000 should be sufficient for most data viz, but can be increased if your data viz screenshot appears cropped too small. #' @return invisible list of ggplots in list format. #' @export -#' @import RJSONIO +#' @import jsonlite #' @importFrom utils browseURL head packageVersion str tail #' write.table #' @example inst/examples/animint2dir.R @@ -637,7 +637,20 @@ animint2dir <- function export.data[[export.name]] <- meta[[export.name]] } } - json <- RJSONIO::toJSON(export.data) + ## Convert named vectors to lists for jsonlite compatibility (issue #193) + ## RJSONIO converts named vectors to JSON objects, jsonlite converts to arrays + ## This helper ensures jsonlite produces the same output as RJSONIO + convert_for_json <- function(x) { + if (is.list(x)) { + lapply(x, convert_for_json) + } else if (is.atomic(x) && !is.null(names(x)) && is.character(x)) { + as.list(x) + } else { + x + } + } + export.data <- convert_for_json(export.data) + json <- jsonlite::toJSON(export.data, auto_unbox = TRUE, force = TRUE) cat(json, file = file.path(out.dir, "plot.json")) if (open.browser) { if (identical(getOption("animint.browser"),"browseURL")) { diff --git a/R/z_pages.R b/R/z_pages.R index e731a41c..5b483491 100644 --- a/R/z_pages.R +++ b/R/z_pages.R @@ -208,7 +208,7 @@ update_gallery <- function(gallery_path="~/R/gallery"){ } local.json <- tempfile() download.file(viz_url("plot.json"), local.json) - jlist <- RJSONIO::fromJSON(local.json) + jlist <- jsonlite::fromJSON(local.json, simplifyVector = FALSE) to.check <- c( source="URL of data viz source code", title="string describing the data viz") diff --git a/inst/examples/gps.R b/inst/examples/gps.R index a8d5fe3e..aa8a0e2c 100644 --- a/inst/examples/gps.R +++ b/inst/examples/gps.R @@ -61,7 +61,7 @@ if(!file.exists(ile_de_france.json)){ ile_de_france.json) download.file(u, ile_de_france.json) } -##ile_de_france.list <- RJSONIO::fromJSON(ile_de_france.json) +##ile_de_france.list <- jsonlite::fromJSON(ile_de_france.json) ile_de_france.sf <- geojsonsf::geojson_sf(ile_de_france.json) names(ile_de_france.sf) class(ile_de_france.sf) diff --git a/tests/testthat/test-renderer3-chunk-NA-separate-lines.R b/tests/testthat/test-renderer3-chunk-NA-separate-lines.R index 5c8f1331..13312489 100644 --- a/tests/testthat/test-renderer3-chunk-NA-separate-lines.R +++ b/tests/testthat/test-renderer3-chunk-NA-separate-lines.R @@ -76,7 +76,7 @@ test_that("geom2 common chunk with group=1 and color common", { geom1.dt <- fread(geom1.tsv) expect_equal(sum(is.na(geom1.dt)), 0) plot.json <- file.path("animint-htmltest", "plot.json") - json.list <- RJSONIO::fromJSON(plot.json) + json.list <- jsonlite::fromJSON(plot.json, simplifyVector = FALSE) group_num <- json.list$geoms$geom2_path_selected$chunks[["San Marcos"]] geom.tsv <- sprintf("animint-htmltest/geom2_path_selected_chunk%d.tsv", group_num) geom.dt <- fread(geom.tsv) @@ -125,7 +125,7 @@ test_that("geom2 common chunk with no group and color common", { geom1.dt <- fread(geom1.tsv) expect_equal(sum(is.na(geom1.dt)), 0) plot.json <- file.path("animint-htmltest", "plot.json") - json.list <- RJSONIO::fromJSON(plot.json) + json.list <- jsonlite::fromJSON(plot.json, simplifyVector = FALSE) group_num <- json.list$geoms$geom2_path_selected$chunks[["San Marcos"]] geom.tsv <- sprintf("animint-htmltest/geom2_path_selected_chunk%d.tsv", group_num) geom.dt <- fread(geom.tsv) @@ -176,7 +176,7 @@ test_that("geom2 common chunk ok with group=1 and only x common", { geom1.dt <- fread(geom1.tsv) expect_equal(sum(is.na(geom1.dt)), 0) plot.json <- file.path("animint-htmltest", "plot.json") - json.list <- RJSONIO::fromJSON(plot.json) + json.list <- jsonlite::fromJSON(plot.json, simplifyVector = FALSE) group_num <- json.list$geoms$geom2_path_selected$chunks[["San Marcos"]] geom.tsv <- sprintf("animint-htmltest/geom2_path_selected_chunk%d.tsv", group_num) geom.dt <- fread(geom.tsv) From 600b97d1ca29d97830422f4c6a64e4284cf24fde Mon Sep 17 00:00:00 2001 From: Gauarv Chaudhary Date: Sat, 10 Jan 2026 20:30:18 +0530 Subject: [PATCH 3/5] The previous fix only converted named character vectors, but RJSONIO also converts named numeric and integer vectors to JSON objects. This was causing 349 test failures in JS_coverage and R_coverage. --- R/z_animint.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/z_animint.R b/R/z_animint.R index 2f8dde06..7fd0a9f8 100644 --- a/R/z_animint.R +++ b/R/z_animint.R @@ -643,7 +643,7 @@ animint2dir <- function convert_for_json <- function(x) { if (is.list(x)) { lapply(x, convert_for_json) - } else if (is.atomic(x) && !is.null(names(x)) && is.character(x)) { + } else if (is.atomic(x) && !is.null(names(x))) { as.list(x) } else { x From bbcc1e73224d85db513cd1f8ef24c73ffe83c087 Mon Sep 17 00:00:00 2001 From: Gauarv Chaudhary Date: Sat, 10 Jan 2026 21:18:03 +0530 Subject: [PATCH 4/5] fix: Add null='null' parameter to ensure NULL values serialize correctly Without this parameter, jsonlite outputs {} instead of null for NULL values. This was causing JavaScript errors when checking for null values in plot.json fields like span.rowspan and span.colspan, resulting in 350 test failures in JS_coverage and R_coverage. --- R/z_animint.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/z_animint.R b/R/z_animint.R index 7fd0a9f8..10f64ba9 100644 --- a/R/z_animint.R +++ b/R/z_animint.R @@ -650,7 +650,7 @@ animint2dir <- function } } export.data <- convert_for_json(export.data) - json <- jsonlite::toJSON(export.data, auto_unbox = TRUE, force = TRUE) + json <- jsonlite::toJSON(export.data, auto_unbox = TRUE, force = TRUE, null = "null") cat(json, file = file.path(out.dir, "plot.json")) if (open.browser) { if (identical(getOption("animint.browser"),"browseURL")) { From c7d4134b04abf115aca3a248c19f3190eaab7e15 Mon Sep 17 00:00:00 2001 From: Gauarv Chaudhary Date: Mon, 9 Feb 2026 10:55:29 +0530 Subject: [PATCH 5/5] fix: jsonlite output matches RJSONIO (#193) - Convert data.frames to column-wise lists before toJSON - Keep NULL as null and preserve named vectors - Migration tests pass Co-authored-by: Cursor --- DESCRIPTION | 1 + NAMESPACE | 2 +- R/z_animint.R | 11 +++++++---- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 2e71a75e..3fe0547b 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -95,6 +95,7 @@ Suggests: covr, RColorBrewer, htmltools, + markdown, rmarkdown, testthat, XML, diff --git a/NAMESPACE b/NAMESPACE index fea0f31b..d75693a9 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -503,10 +503,10 @@ export(xlim) export(ylab) export(ylim) export(zeroGrob) -import(jsonlite) import(data.table) import(grid) import(gtable) +import(jsonlite) import(plyr) import(scales) importFrom(grDevices,col2rgb) diff --git a/R/z_animint.R b/R/z_animint.R index 10f64ba9..0c172e6d 100644 --- a/R/z_animint.R +++ b/R/z_animint.R @@ -637,11 +637,14 @@ animint2dir <- function export.data[[export.name]] <- meta[[export.name]] } } - ## Convert named vectors to lists for jsonlite compatibility (issue #193) - ## RJSONIO converts named vectors to JSON objects, jsonlite converts to arrays - ## This helper ensures jsonlite produces the same output as RJSONIO + ## Convert R objects for jsonlite compatibility (issue #193). + ## RJSONIO serializes data.frames column-wise and named vectors as + ## objects; jsonlite serializes data.frames row-wise and drops vector + ## names. This helper converts both so jsonlite output matches RJSONIO. convert_for_json <- function(x) { - if (is.list(x)) { + if (is.data.frame(x)) { + lapply(as.list(x), identity) + } else if (is.list(x)) { lapply(x, convert_for_json) } else if (is.atomic(x) && !is.null(names(x))) { as.list(x)