Skip to content

Commit 7c5f934

Browse files
author
vlarmet
committed
Merge branch 'test/cch-suite'
2 parents 5692970 + c0871b5 commit 7c5f934

20 files changed

Lines changed: 1013 additions & 3 deletions

.Rbuildignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55
^README\.md$
66
^README_files/
77
^\.git$
8-
^\.gitignore$
8+
^\.gitignore$
9+
^\.github$

.github/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.html

.github/workflows/R-CMD-check.yaml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples
2+
# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help
3+
on:
4+
push:
5+
branches: [main, master]
6+
pull_request:
7+
8+
name: R-CMD-check.yaml
9+
10+
permissions: read-all
11+
12+
jobs:
13+
R-CMD-check:
14+
runs-on: ${{ matrix.config.os }}
15+
16+
name: ${{ matrix.config.os }} (${{ matrix.config.r }})
17+
18+
strategy:
19+
fail-fast: false
20+
matrix:
21+
config:
22+
- {os: macos-latest, r: 'release'}
23+
- {os: windows-latest, r: 'release'}
24+
- {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'}
25+
- {os: ubuntu-latest, r: 'release'}
26+
- {os: ubuntu-latest, r: 'oldrel-1'}
27+
28+
env:
29+
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
30+
R_KEEP_PKG_SOURCE: yes
31+
32+
steps:
33+
- uses: actions/checkout@v6
34+
35+
- uses: r-lib/actions/setup-pandoc@v2
36+
37+
- uses: r-lib/actions/setup-r@v2
38+
with:
39+
r-version: ${{ matrix.config.r }}
40+
http-user-agent: ${{ matrix.config.http-user-agent }}
41+
42+
- uses: r-lib/actions/setup-r-dependencies@v2
43+
with:
44+
extra-packages: any::rcmdcheck
45+
needs: check
46+
47+
- uses: r-lib/actions/check-r-package@v2
48+
with:
49+
upload-snapshots: true
50+
build_args: 'c("--no-manual","--compact-vignettes=gs+qpdf")'

DESCRIPTION

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@ Description: Calculation of distances, shortest paths and isochrones on weighted
2020
License: GPL (>= 2)
2121
Encoding: UTF-8
2222
LazyData: true
23-
Imports: Rcpp (>= 1.0.7), RcppParallel, RcppProgress, data.table
23+
Imports: Rcpp (>= 1.0.7), RcppParallel, data.table
2424
LinkingTo: Rcpp, RcppParallel, RcppProgress
2525
SystemRequirements: GNU make
2626
URL: https://github.com/vlarmet/cppRouting
27-
Suggests: knitr, rmarkdown, igraph
27+
Suggests: knitr, rmarkdown, igraph, testthat (>= 3.0.0)
2828
VignetteBuilder: knitr
2929
NeedsCompilation: yes
3030
Config/roxygen2/version: 8.0.0
31+
Config/testthat/edition: 3

tests/testthat.R

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
library(testthat)
2+
library(cppRouting)
3+
4+
test_check("cppRouting")

tests/testthat/helper-graphs.R

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Shared fixtures for the test suite. Kept small and deterministic (no real
2+
# road data - that lives in data_readme/ and is used by the bench_*.R
3+
# performance scripts instead, not by unit tests).
4+
5+
# A hand-verifiable directed graph, small enough that shortest paths/costs
6+
# can be checked by inspection. Includes:
7+
# - a parallel edge pair (1->2) with different costs, to exercise
8+
# "cheapest parallel edge wins" (a real bug class hit repeatedly while
9+
# building aggregate_aux)
10+
# - an exact tie: two paths of equal total weight but different edge counts
11+
# (1->4 via 1-2-4 and 1-3-4, both cost 5, hop count 2 either way here -
12+
# kept simple; ties-with-different-hop-count are exercised in the bigger
13+
# fixture below since they need more structure)
14+
toy_edges <- function() {
15+
data.frame(
16+
from = c(1, 1, 1, 2, 2, 3, 4, 4, 5),
17+
to = c(2, 2, 3, 4, 5, 4, 5, 6, 6),
18+
dist = c(2, 5, 3, 3, 8, 2, 1, 6, 1),
19+
stringsAsFactors = FALSE
20+
)
21+
}
22+
23+
toy_aux <- function() {
24+
e <- toy_edges()
25+
data.frame(hop = rep(1, nrow(e)), dist2 = e$dist * 2)
26+
}
27+
28+
toy_graph <- function(directed = TRUE, aux = NULL) {
29+
cppRouting::makegraph(toy_edges(), directed = directed, aux = aux)
30+
}
31+
32+
# A small grid graph: realistic/near-planar topology (unlike a fully random
33+
# graph, which is an adversarial worst case for both CH and CCH elimination
34+
# ordering - see min_degree_order fill-in blowup found during development).
35+
# Big enough to exercise contraction/CCH/simplification meaningfully, small
36+
# enough to run fast. Nodes are numbered row-major, 1-based.
37+
grid_edges <- function(nrow = 6, ncol = 6, directed = TRUE, seed = 1) {
38+
set.seed(seed)
39+
node_id <- function(r, c) (r - 1) * ncol + c
40+
from <- integer(0); to <- integer(0)
41+
for (r in seq_len(nrow)) {
42+
for (c in seq_len(ncol)) {
43+
if (c < ncol) { from <- c(from, node_id(r, c)); to <- c(to, node_id(r, c + 1)) }
44+
if (r < nrow) { from <- c(from, node_id(r, c)); to <- c(to, node_id(r + 1, c)) }
45+
}
46+
}
47+
weight <- round(runif(length(from), 1, 10), 3)
48+
data.frame(from = from, to = to, weight = weight)
49+
}
50+
51+
grid_coords <- function(nrow = 6, ncol = 6) {
52+
node_id <- function(r, c) (r - 1) * ncol + c
53+
ids <- integer(0); x <- numeric(0); y <- numeric(0)
54+
for (r in seq_len(nrow)) for (c in seq_len(ncol)) {
55+
ids <- c(ids, node_id(r, c)); x <- c(x, c); y <- c(y, r)
56+
}
57+
data.frame(id = ids, x = x, y = y)
58+
}
59+
60+
# Grid graph with two aux columns (a constant "hop" column, and one that
61+
# mirrors the main weight), coordinates, and capacity/alpha/beta so it can
62+
# be reused for aggregate_aux, A*/NBA and traffic-assignment tests alike.
63+
grid_graph <- function(nrow = 6, ncol = 6, directed = TRUE, seed = 1,
64+
with_coords = FALSE, with_aux = FALSE,
65+
with_traffic_attrs = FALSE) {
66+
e <- grid_edges(nrow, ncol, directed, seed)
67+
args <- list(df = e, directed = directed)
68+
if (with_coords) args$coords <- grid_coords(nrow, ncol)
69+
if (with_aux) args$aux <- data.frame(hop = rep(1, nrow(e)), dist2 = e$weight)
70+
if (with_traffic_attrs) {
71+
args$capacity <- round(runif(nrow(e), 50, 200), 1)
72+
args$alpha <- rep(0.15, nrow(e))
73+
args$beta <- rep(4, nrow(e))
74+
}
75+
do.call(cppRouting::makegraph, args)
76+
}
77+
78+
# Duplicated edge with a strictly higher cost than its twin, to make sure
79+
# tests exercise "cheapest parallel edge wins" rather than an arbitrary one.
80+
with_parallel_edge <- function(edges, from, to, extra_cost = 5) {
81+
base <- edges[edges$from == from & edges$to == to, ]
82+
stopifnot(nrow(base) >= 1)
83+
dup <- base[1, ]
84+
dup[[3]] <- dup[[3]] + extra_cost
85+
rbind(edges, dup)
86+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# aggregate_aux: single legacy vector, single named column out of a
2+
# data.frame, and multi-column data.frame - across get_distance_pair(),
3+
# get_distance_matrix(), normal/CH/CCH graphs and cpp_simplify().
4+
5+
# A strictly linear, tie-free graph: only one possible path between any two
6+
# nodes, so aggregate_aux values can be hand-verified exactly.
7+
line_edges <- function() {
8+
data.frame(from = c(1, 2, 3), to = c(2, 3, 4), dist = c(4, 6, 2))
9+
}
10+
11+
test_that("legacy single aux vector: aggregate_aux = TRUE returns a plain vector/matrix", {
12+
e <- line_edges()
13+
g <- makegraph(e, directed = TRUE, aux = e$dist * 2) # aux = 2x the main weight
14+
15+
d <- get_distance_pair(g, 1, 4)
16+
a <- get_distance_pair(g, 1, 4, aggregate_aux = TRUE)
17+
expect_true(is.numeric(a) && !is.list(a))
18+
expect_equal(a, 2 * d)
19+
20+
m <- get_distance_matrix(g, c(1, 2), c(3, 4))
21+
am <- get_distance_matrix(g, c(1, 2), c(3, 4), aggregate_aux = TRUE)
22+
expect_true(is.matrix(am))
23+
expect_equal(unname(am), unname(2 * m))
24+
})
25+
26+
test_that("data.frame aux with a single column resolved returns a length-1 list, not a bare vector/matrix", {
27+
e <- line_edges()
28+
g <- makegraph(e, directed = TRUE, aux = data.frame(dist2 = e$dist * 2))
29+
30+
a <- get_distance_pair(g, 1, 4, aggregate_aux = TRUE)
31+
expect_s3_class(a, "data.frame")
32+
expect_named(a, "dist2")
33+
expect_equal(a$dist2, 2 * get_distance_pair(g, 1, 4))
34+
35+
am <- get_distance_matrix(g, c(1, 2), c(3, 4), aggregate_aux = TRUE)
36+
expect_type(am, "list")
37+
expect_named(am, "dist2")
38+
expect_true(is.matrix(am$dist2))
39+
})
40+
41+
test_that("multi-column data.frame aux: aggregate_aux = TRUE returns all columns, named", {
42+
e <- line_edges()
43+
g <- makegraph(e, directed = TRUE, aux = data.frame(hop = rep(1, nrow(e)), dist2 = e$dist * 2))
44+
d <- get_distance_pair(g, 1, 4)
45+
46+
a <- get_distance_pair(g, 1, 4, aggregate_aux = TRUE)
47+
expect_s3_class(a, "data.frame")
48+
expect_named(a, c("hop", "dist2"))
49+
expect_equal(a$hop, 3) # 3 edges on the only path 1->2->3->4
50+
expect_equal(a$dist2, 2 * d)
51+
52+
am <- get_distance_matrix(g, c(1, 2), c(3, 4), aggregate_aux = TRUE)
53+
expect_type(am, "list")
54+
expect_named(am, c("hop", "dist2"))
55+
})
56+
57+
test_that("character vector aggregate_aux selects a subset of columns", {
58+
e <- line_edges()
59+
g <- makegraph(e, directed = TRUE, aux = data.frame(hop = rep(1, nrow(e)), dist2 = e$dist * 2, dist3 = e$dist * 3))
60+
61+
a <- get_distance_pair(g, 1, 4, aggregate_aux = c("dist2", "dist3"))
62+
expect_named(a, c("dist2", "dist3"))
63+
expect_false("hop" %in% names(a))
64+
65+
a1 <- get_distance_pair(g, 1, 4, aggregate_aux = "hop")
66+
expect_s3_class(a1, "data.frame")
67+
expect_named(a1, "hop")
68+
})
69+
70+
test_that("aggregate_aux errors on unknown column names and on a graph without aux", {
71+
e <- line_edges()
72+
g_no_aux <- makegraph(e, directed = TRUE)
73+
expect_error(get_distance_pair(g_no_aux, 1, 4, aggregate_aux = TRUE))
74+
75+
g <- makegraph(e, directed = TRUE, aux = data.frame(hop = rep(1, nrow(e))))
76+
expect_error(get_distance_pair(g, 1, 4, aggregate_aux = "not_a_column"))
77+
})
78+
79+
test_that("negative aux values trigger a warning at makegraph() time", {
80+
e <- line_edges()
81+
expect_warning(makegraph(e, directed = TRUE, aux = c(-1, 2, 3)))
82+
expect_warning(makegraph(e, directed = TRUE, aux = data.frame(a = c(-1, 2, 3))))
83+
})
84+
85+
test_that("multi-column aggregate_aux agrees between normal graph and CH (no ties)", {
86+
g <- grid_graph(6, 6, directed = TRUE, with_aux = TRUE)
87+
ch <- cpp_contract(g, silent = TRUE)
88+
89+
set.seed(11)
90+
nodes <- g$dict$ref
91+
from <- sample(nodes, 20, replace = TRUE)
92+
to <- sample(nodes, 20, replace = TRUE)
93+
94+
a_normal <- get_distance_pair(g, from, to, aggregate_aux = TRUE)
95+
a_ch <- get_distance_pair(ch, from, to, aggregate_aux = TRUE)
96+
expect_equal(a_normal$hop, a_ch$hop, tolerance = 1e-8)
97+
expect_equal(a_normal$dist2, a_ch$dist2, tolerance = 1e-6)
98+
99+
m_normal <- get_distance_matrix(g, from[1:6], to[1:6], aggregate_aux = TRUE)
100+
m_ch <- get_distance_matrix(ch, from[1:6], to[1:6], aggregate_aux = TRUE)
101+
expect_equal(unname(m_normal$hop), unname(m_ch$hop), tolerance = 1e-8)
102+
expect_equal(unname(m_normal$dist2), unname(m_ch$dist2), tolerance = 1e-6)
103+
})
104+
105+
test_that("aggregate_aux on a tied-cost graph: the aux column proportional to the main weight always matches exactly, even when paths differ", {
106+
# toy_graph has genuine cost ties (e.g. 1->4 via 1-2-4 or 1-3-4, both cost
107+
# 5) - documented, expected behavior (see get_distance_pair()'s @details).
108+
# dist2 = 2 * dist is linear in the optimized weight, so its sum along any
109+
# optimal path always equals 2 * distance, regardless of which tied path a
110+
# given algorithm/graph representation happens to pick.
111+
g <- toy_graph(aux = toy_aux())
112+
ch <- cpp_contract(g, silent = TRUE)
113+
114+
nodes <- g$dict$ref
115+
from <- rep(nodes, times = length(nodes))
116+
to <- rep(nodes, each = length(nodes))
117+
118+
d_normal <- get_distance_pair(g, from, to)
119+
a_normal <- get_distance_pair(g, from, to, aggregate_aux = TRUE)
120+
a_ch <- get_distance_pair(ch, from, to, aggregate_aux = TRUE)
121+
122+
ok <- !is.na(d_normal)
123+
expect_equal(a_normal$dist2[ok], 2 * d_normal[ok], tolerance = 1e-8)
124+
expect_equal(a_ch$dist2[ok], 2 * d_normal[ok], tolerance = 1e-8)
125+
})
126+
127+
test_that("cpp_simplify sums multi-column aux correctly across merged edge chains", {
128+
# a-b-c-d is a pure chain (b, c are non-intersection nodes and get
129+
# contracted away by cpp_simplify); the merged edge's aux must equal the
130+
# sum over the 3 original edges.
131+
e <- data.frame(from = c("a", "b", "c"), to = c("b", "c", "d"), dist = c(1, 2, 3))
132+
aux <- data.frame(hop = c(1, 1, 1), dist2 = e$dist * 2)
133+
g <- makegraph(e, directed = TRUE, aux = aux)
134+
135+
simplified <- cpp_simplify(g, iterate = TRUE)
136+
expect_equal(nrow(simplified$data), 1) # a-b-c-d collapsed to a single a->d edge
137+
138+
a <- get_distance_pair(simplified, "a", "d", aggregate_aux = TRUE)
139+
expect_equal(a$hop, 3)
140+
expect_equal(a$dist2, sum(aux$dist2))
141+
})
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
traffic_graph <- function() {
2+
grid_graph(4, 4, directed = TRUE, with_traffic_attrs = TRUE)
3+
}
4+
5+
test_that("link-based algorithms (msa, fw, cfw, bfw) converge to a small gap", {
6+
g <- traffic_graph()
7+
set.seed(41)
8+
nodes <- g$dict$ref
9+
demand <- data.frame(from = sample(nodes, 5), to = sample(nodes, 5), demand = runif(5, 1, 10))
10+
11+
for (algo in c("msa", "fw", "cfw", "bfw")) {
12+
res <- assign_traffic(g, from = demand$from, to = demand$to, demand = demand$demand,
13+
algorithm = algo, max_it = 50, verbose = FALSE)
14+
expect_true(is.finite(res$gap))
15+
expect_gte(res$gap, 0)
16+
expect_true(all(res$data$flow >= 0))
17+
}
18+
})
19+
20+
test_that("bush-based algorithm (dial) converges to a small gap", {
21+
g <- traffic_graph()
22+
set.seed(42)
23+
nodes <- g$dict$ref
24+
demand <- data.frame(from = sample(nodes, 5), to = sample(nodes, 5), demand = runif(5, 1, 10))
25+
26+
res <- assign_traffic(g, from = demand$from, to = demand$to, demand = demand$demand,
27+
algorithm = "dial", max_it = 10, verbose = FALSE)
28+
expect_true(is.finite(res$gap))
29+
expect_gte(res$gap, 0)
30+
})
31+
32+
test_that("aon_method = 'cch' auto-prepares a CCH when cch = NULL, and matches aon_method = 'bi'", {
33+
g <- traffic_graph()
34+
set.seed(43)
35+
nodes <- g$dict$ref
36+
demand <- data.frame(from = sample(nodes, 5), to = sample(nodes, 5), demand = runif(5, 1, 10))
37+
38+
# auto-preparation (cch = NULL) shows a progress bar for the underlying
39+
# cpp_contract(customizable = TRUE) call; captured here to keep test output
40+
# clean, not asserted on.
41+
invisible(capture.output(
42+
res_cch <- assign_traffic(g, from = demand$from, to = demand$to, demand = demand$demand,
43+
algorithm = "bfw", max_it = 50, aon_method = "cch", verbose = FALSE)
44+
))
45+
res_bi <- assign_traffic(g, from = demand$from, to = demand$to, demand = demand$demand,
46+
algorithm = "bfw", max_it = 50, aon_method = "bi", verbose = FALSE)
47+
expect_equal(res_cch$gap, res_bi$gap, tolerance = 1e-6)
48+
})
49+
50+
test_that("a cch prepared for a different graph is rejected with a clear error", {
51+
g1 <- traffic_graph()
52+
g2 <- grid_graph(6, 6, directed = TRUE, with_traffic_attrs = TRUE) # different topology
53+
54+
wrong_cch <- cpp_contract(g2, customizable = TRUE, silent = TRUE)
55+
56+
demand <- data.frame(from = g1$dict$ref[1:3], to = g1$dict$ref[4:6], demand = c(1, 2, 3))
57+
expect_error(
58+
assign_traffic(g1, from = demand$from, to = demand$to, demand = demand$demand,
59+
algorithm = "bfw", aon_method = "cch", cch = wrong_cch, verbose = FALSE),
60+
"topology|different number"
61+
)
62+
})
63+
64+
test_that("from/to/demand must have the same length", {
65+
g <- traffic_graph()
66+
expect_error(assign_traffic(g, from = c("1", "2"), to = "1", demand = c(1, 2)))
67+
})
68+
69+
test_that("alpha, beta and capacity must be defined on the graph", {
70+
g <- grid_graph(4, 4, directed = TRUE) # no traffic attributes
71+
expect_error(assign_traffic(g, from = "1", to = "2", demand = 1))
72+
})

0 commit comments

Comments
 (0)