Skip to content

Commit e0adbaf

Browse files
authored
Merge pull request #6 from flyconnectome/add-test-coverage
Add tests for previously-uncovered paths (34% -> 66% coverage)
2 parents 2ef2140 + d399cd7 commit e0adbaf

6 files changed

Lines changed: 205 additions & 1 deletion

File tree

‎.github/workflows/test-coverage.yaml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ jobs:
3737
shell: Rscript {0}
3838
run: |
3939
reticulate::install_miniconda()
40-
reticulate::py_install(c("numpy", "pandas"))
40+
# pyarrow lets the use_arrow = TRUE conversion path be exercised
41+
reticulate::py_install(c("numpy", "pandas", "pyarrow"))
4142
pak::local_install()
4243
cat(sprintf("RETICULATE_PYTHON=%s\n", reticulate::py_config()$python),
4344
file = Sys.getenv("GITHUB_ENV"), append = TRUE)

‎tests/testthat/test-convert.R‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,63 @@ test_that("pandas2df recovers 64-bit ids and object columns", {
119119
expect_identical(out$label, c("a", "b"))
120120
})
121121

122+
test_that("pandas2df flattens object columns and normalises datetimes", {
123+
skip_if_no_module("pandas")
124+
# An object column of python ints (flattened to an atomic vector), a genuine
125+
# list-valued object column (a multi-select, left as a list), an all-None
126+
# object column (-> NA), and a datetime column (-> UTC POSIXct).
127+
df <- reticulate::py_eval(
128+
paste0("__import__('pandas').DataFrame({",
129+
"'n': __import__('pandas').Series([1, 2, 3], dtype=object), ",
130+
"'tags': __import__('pandas').Series([['AB'], ['CD'], []], dtype=object), ",
131+
"'blank': __import__('pandas').Series([None, None, None], dtype=object), ",
132+
"'when': __import__('pandas').to_datetime(",
133+
"['2020-01-01', '2020-01-02', '2020-01-03'])})"),
134+
convert = FALSE)
135+
out <- pandas2df(df)
136+
# object ints flattened to a plain atomic vector, not a list of length-1s
137+
expect_false(is.list(out$n))
138+
expect_identical(out$n, c(1L, 2L, 3L))
139+
# a genuine list-valued (multi-select) column is left intact as a list
140+
expect_true(is.list(out$tags))
141+
expect_identical(out$tags[[1]], "AB")
142+
expect_length(out$tags[[3]], 0L)
143+
# an all-None object column collapses to NA
144+
expect_true(all(is.na(out$blank)))
145+
# datetimes come back as UTC POSIXct
146+
expect_s3_class(out$when, "POSIXct")
147+
expect_identical(attr(out$when, "tzone"), "UTC")
148+
expect_identical(as.character(as.Date(out$when)),
149+
c("2020-01-01", "2020-01-02", "2020-01-03"))
150+
})
151+
152+
test_that("pandas2df use_arrow path round-trips via a feather file", {
153+
skip_if_no_module("pandas")
154+
skip_if_no_module("pyarrow") # pandas.to_feather needs pyarrow
155+
skip_if_not_installed("arrow") # R arrow::read_feather
156+
df <- reticulate::py_eval(
157+
"__import__('pandas').DataFrame({'label': ['a', 'b', 'c'], 'n': [1, 2, 3]})",
158+
convert = FALSE)
159+
out <- pandas2df(df, use_arrow = TRUE)
160+
# the arrow path always returns a tibble
161+
expect_s3_class(out, "tbl_df")
162+
expect_identical(out$label, c("a", "b", "c"))
163+
expect_equal(as.integer(out$n), 1:3)
164+
})
165+
166+
test_that("pandas2df use_arrow handles an empty frame", {
167+
skip_if_no_module("pandas")
168+
skip_if_not_installed("arrow")
169+
skip_if_not_installed("tibble")
170+
# the empty-frame branch returns via py_to_r + tibble, without touching arrow
171+
df <- reticulate::py_eval(
172+
"__import__('pandas').DataFrame({'label': [], 'n': []})", convert = FALSE)
173+
out <- pandas2df(df, use_arrow = TRUE)
174+
expect_s3_class(out, "tbl_df")
175+
expect_identical(nrow(out), 0L)
176+
expect_identical(names(out), c("label", "n"))
177+
})
178+
122179
test_that("pandas2df fast-path preserves column order and nullable ids", {
123180
skip_if_no_module("pandas")
124181
ids <- c("720575940621039145", "720575940626877799")

‎tests/testthat/test-env.R‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Pure-R helpers in env.R. The provisioning machinery itself (install_miniconda,
2+
# conda_install, ...) has real side effects and is not unit-tested here; these
3+
# cover the option/env plumbing and the non-destructive "print the command"
4+
# branches of simple_python_base().
5+
6+
test_that("np_condaenv honours the option and defaults to r-reticulate", {
7+
withr::local_options(nat.python.condaenv = NULL)
8+
expect_identical(np_condaenv(), "r-reticulate")
9+
withr::local_options(nat.python.condaenv = "my-env")
10+
expect_identical(np_condaenv(), "my-env")
11+
})
12+
13+
test_that("ownpythonrequested reflects RETICULATE_PYTHON", {
14+
withr::local_envvar(RETICULATE_PYTHON = "")
15+
expect_false(ownpythonrequested())
16+
withr::local_envvar(RETICULATE_PYTHON = "/opt/python/bin/python")
17+
expect_true(ownpythonrequested())
18+
})
19+
20+
test_that("checkownpython aborts for a non-standard Python", {
21+
withr::local_envvar(RETICULATE_PYTHON = "")
22+
# miniconda = FALSE means the user asked for their own Python
23+
expect_error(checkownpython(miniconda = FALSE), "on your own")
24+
withr::local_envvar(RETICULATE_PYTHON = "/opt/python/bin/python")
25+
expect_error(checkownpython(miniconda = TRUE), "on your own")
26+
# standard managed setup: no abort
27+
withr::local_envvar(RETICULATE_PYTHON = "")
28+
expect_silent(checkownpython(miniconda = TRUE))
29+
})
30+
31+
test_that("check_reticulate is a no-op when check_python = FALSE", {
32+
expect_true(check_reticulate(check_python = FALSE))
33+
expect_invisible(check_reticulate(check_python = FALSE))
34+
})
35+
36+
test_that("simple_python_base blast only prints and deletes nothing", {
37+
withr::local_envvar(RETICULATE_PYTHON = "")
38+
# the blast branch just prints the (destructive) unlink command; it must
39+
# never touch the filesystem itself, and returns invisibly
40+
existed <- dir.exists(reticulate::miniconda_path())
41+
expect_invisible(res <- simple_python_base("blast", miniconda = TRUE))
42+
expect_null(res)
43+
# the miniconda directory is left exactly as it was
44+
expect_identical(dir.exists(reticulate::miniconda_path()), existed)
45+
})

‎tests/testthat/test-int64.R‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,53 @@ test_that("rids2raw honours endianness", {
2424
expect_false(identical(rids2raw(1:3, endian = "little"),
2525
rids2raw(1:3, endian = "big")))
2626
})
27+
28+
test_that("pyids2bit64 round-trips a numpy int64 array", {
29+
skip_if_no_module("numpy")
30+
np <- reticulate::import("numpy", convert = FALSE)
31+
ids <- c("123", "720575940621039145", "9223372036854775807")
32+
arr <- np$array(ids, dtype = "int64")
33+
# default: character out
34+
expect_identical(pyids2bit64(arr), ids)
35+
# as_character = FALSE: an exact integer64 vector
36+
i64 <- pyids2bit64(arr, as_character = FALSE)
37+
expect_s3_class(i64, "integer64")
38+
expect_identical(as.character(i64), ids)
39+
})
40+
41+
test_that("pyids2bit64 accepts uint64 within signed range and rejects overflow", {
42+
skip_if_no_module("numpy")
43+
np <- reticulate::import("numpy", convert = FALSE)
44+
ok <- np$array(c("123", "9223372036854775807"), dtype = "uint64")
45+
expect_identical(pyids2bit64(ok), c("123", "9223372036854775807"))
46+
# a uint64 value beyond the signed range cannot be represented as int64
47+
ov <- np$array(c("18446744073709551615"), dtype = "uint64")
48+
expect_error(pyids2bit64(ov), "int64 overflow")
49+
# unsupported dtype is rejected
50+
f <- np$array(c("1.5"), dtype = "float64")
51+
expect_error(pyids2bit64(f), "dtype=int64 or uint64")
52+
})
53+
54+
test_that("pyids2bit64 handles an empty array", {
55+
skip_if_no_module("numpy")
56+
np <- reticulate::import("numpy", convert = FALSE)
57+
empty <- np$array(list(), dtype = "int64")
58+
expect_identical(pyids2bit64(empty), character())
59+
expect_identical(pyids2bit64(empty, as_character = FALSE), bit64::integer64())
60+
})
61+
62+
test_that("rids2pyint round-trips R ids back through pyids2bit64", {
63+
skip_if_no_module("numpy")
64+
ids <- c("123", "720575940621039145", "9223372036854775807")
65+
# in-memory string path (default for short vectors)
66+
arr <- rids2pyint(ids, numpyarray = TRUE)
67+
expect_identical(pyids2bit64(arr), ids)
68+
# a Python list of ints is the non-numpyarray default
69+
lst <- rids2pyint(ids)
70+
expect_s3_class(lst, "python.builtin.list")
71+
# the file-marshalling path (usefile = TRUE) yields the same ids
72+
arr2 <- rids2pyint(ids, numpyarray = TRUE, usefile = TRUE)
73+
expect_identical(pyids2bit64(arr2), ids)
74+
# a numpy array passed in is returned as-is
75+
expect_identical(pyids2bit64(rids2pyint(arr, numpyarray = TRUE)), ids)
76+
})

‎tests/testthat/test-time.R‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# ts2pydatetime: the early-return for an already-converted datetime is pure R;
2+
# the conversion path needs the Python datetime module.
3+
4+
test_that("ts2pydatetime returns an existing python datetime unchanged", {
5+
# a value already carrying the datetime.datetime class is passed straight
6+
# back, no Python needed
7+
fake <- structure(list(), class = "datetime.datetime")
8+
expect_identical(ts2pydatetime(fake), fake)
9+
})
10+
11+
test_that("ts2pydatetime converts an R time to a UTC python datetime", {
12+
skip_if_no_module("datetime")
13+
t <- as.POSIXct("2020-01-02 03:04:05", tz = "UTC")
14+
py <- ts2pydatetime(t)
15+
expect_s3_class(py, "datetime.datetime")
16+
# tzinfo is made explicit (UTC), and the instant round-trips exactly
17+
expect_true(reticulate::py_to_r(py$tzinfo == reticulate::import("datetime")$timezone$utc))
18+
back <- as.numeric(reticulate::py_to_r(py$timestamp()))
19+
expect_equal(back, as.numeric(t))
20+
})

‎tests/testthat/test-utils.R‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Internal utility helpers in utils.R. check_suggested is pure R; py_np needs
2+
# numpy and is skipped when Python is unavailable.
3+
4+
test_that("check_suggested passes for an installed package", {
5+
# reticulate is a hard dependency, so it is always present
6+
expect_true(check_suggested("reticulate"))
7+
expect_invisible(check_suggested("reticulate"))
8+
})
9+
10+
test_that("check_suggested errors for a missing package with instructions", {
11+
nope <- "a.package.that.does.not.exist"
12+
expect_error(check_suggested(nope), "is required")
13+
expect_error(check_suggested(nope), "install.packages")
14+
# the purpose string is woven into the message when supplied
15+
expect_error(check_suggested(nope, "for the widget path"),
16+
"for the widget path")
17+
})
18+
19+
test_that("py_np imports and caches the numpy module", {
20+
skip_if_no_module("numpy")
21+
# clear any cached handle so we exercise the import branch
22+
rm(list = ls(.nat_python_cache), envir = .nat_python_cache)
23+
np1 <- py_np()
24+
expect_s3_class(np1, "python.builtin.module")
25+
# second call is served from the session cache -- same object handle
26+
np2 <- py_np()
27+
expect_identical(np1, np2)
28+
# convert = TRUE is cached under a separate key
29+
np3 <- py_np(convert = TRUE)
30+
expect_s3_class(np3, "python.builtin.module")
31+
})

0 commit comments

Comments
 (0)