use super::*;
use crate::git::ChangeKind;
use crate::impact::protection::Protection;
use crate::impact::role::role;
use crate::impact::structural_json::JsonStructural;
use crate::impact::structural_report::{reach_line, render, summary};
const INSIGHTS: &str = "\
defmodule Booking.Insights do
alias Booking.Repo
defstruct [:id]
def arrange(insight, user) do
record(insight, user, :arranged)
end
def list, do: Repo.all(__MODULE__)
defp record(insight, _user, state) do
Repo.insert!(%{insight | state: state})
end
end
";
fn line_of(needle: &str) -> usize {
INSIGHTS.lines().position(|l| l.contains(needle)).unwrap() + 1
}
fn project(extra: &[(&str, &str)]) -> Vec<(PathBuf, String)> {
let base = [
("lib/booking/insights.ex", INSIGHTS),
(
"lib/booking/repo.ex",
"defmodule Booking.Repo do\n def all(q), do: q\n def insert!(x), do: x\nend\n",
),
(
"lib/booking_web/controllers/insight_controller.ex",
"defmodule BookingWeb.InsightController do\n alias Booking.Insights\n def create(conn, p), do: Insights.arrange(p, conn.assigns.user)\nend\n",
),
(
"lib/booking/report.ex",
"defmodule Booking.Report do\n def run, do: Booking.Insights.list()\nend\n",
),
(
"lib/booking/export.ex",
"defmodule Booking.Export do\n def header(%Booking.Insights{} = i), do: i.id\nend\n",
),
(
"lib/booking/mailer.ex",
"defmodule Booking.Mailer do\n def send, do: Booking.Report.run()\nend\n",
),
(
"test/booking/report_test.exs",
"defmodule Booking.ReportTest do\n test \"run\" do\n assert Booking.Report.run()\n end\nend\n",
),
(
"test/support/factory.ex",
"defmodule Booking.Factory do\n def build, do: {Booking.Export.header(nil), BookingWeb.InsightController}\nend\n",
),
(
"config/config.exs",
"import Config\nconfig :booking, Booking.Export, []\n",
),
("mix.exs", "defmodule Booking.MixProject do\nend\n"),
];
base.iter()
.chain(extra)
.map(|(path, text)| (PathBuf::from(path), text.to_string()))
.collect()
}
fn change(path: &str, lines: &[usize]) -> FileDiffStat {
FileDiffStat {
path: PathBuf::from(path),
old_path: Some(PathBuf::from(path)),
kind: ChangeKind::Modified,
added: lines.len(),
deleted: 0,
lines: lines.to_vec(),
old_lines: lines.to_vec(),
probe: Vec::new(),
}
}
fn radius(sources: Vec<(PathBuf, String)>, changes: &[FileDiffStat]) -> Structural {
let changed: Vec<Changed> = changes.iter().collect();
compute(sources, &changed, &|_, _| false)
}
fn direct(radius: &Structural) -> Vec<(&str, Exposure, usize, Vec<&str>)> {
radius
.direct
.iter()
.map(|d| {
(
d.file.file_name().unwrap().to_str().unwrap(),
d.exposure,
d.tests,
d.calls.iter().map(String::as_str).collect(),
)
})
.collect()
}
const CONTROLLER_TEST: (&str, &str) = (
"test/booking_web/controllers/insight_controller_test.exs",
"defmodule BookingWeb.InsightControllerTest do\n test \"create\", %{conn: conn} do\n assert post(conn, \"/insights\")\n end\nend\n",
);
#[test]
fn a_change_inside_a_function_exposes_those_who_call_it() {
let changes = [change(
"lib/booking/insights.ex",
&[line_of("record(insight, user, :arranged)")],
)];
let radius = radius(project(&[CONTROLLER_TEST]), &changes);
assert_eq!(radius.origins, [PathBuf::from("lib/booking/insights.ex")]);
assert_eq!(
radius.functions.as_deref(),
Some(&["arrange".to_string()][..])
);
assert_eq!(
direct(&radius),
[
(
"insight_controller.ex",
Exposure::Calls,
1,
vec!["Insights.arrange"]
),
("export.ex", Exposure::Refers, 0, vec![]),
("report.ex", Exposure::Elsewhere, 1, vec![]),
]
);
assert_eq!(radius.unprotected().count(), 0);
assert_eq!(radius.exposed().count(), 2);
assert_eq!(radius.source_files, 6);
assert_eq!(radius.by_distance, [1]);
assert_eq!(radius.reached(), 1);
assert_eq!(radius.upper_bound, 4);
}
#[test]
fn a_change_to_a_private_function_exposes_the_callers_of_what_reaches_it() {
let changes = [change(
"lib/booking/insights.ex",
&[line_of("Repo.insert!")],
)];
let radius = radius(project(&[]), &changes);
assert_eq!(
radius.functions.as_deref(),
Some(&["arrange".to_string()][..])
);
assert_eq!(
direct(&radius)[0],
(
"insight_controller.ex",
Exposure::Calls,
0,
vec!["Insights.arrange"]
)
);
let unprotected: Vec<&Path> = radius.unprotected().map(|d| d.file.as_path()).collect();
assert_eq!(
unprotected,
[Path::new(
"lib/booking_web/controllers/insight_controller.ex"
)]
);
}
#[test]
fn a_change_outside_functions_exposes_every_user_of_the_file() {
let changes = [change("lib/booking/insights.ex", &[line_of("defstruct")])];
let radius = radius(project(&[]), &changes);
assert_eq!(radius.functions, None);
assert_eq!(
direct(&radius),
[
("export.ex", Exposure::Calls, 0, vec![]),
(
"insight_controller.ex",
Exposure::Calls,
0,
vec!["Insights.arrange"]
),
("report.ex", Exposure::Calls, 1, vec!["Insights.list"]),
]
);
assert_eq!(radius.unprotected().count(), 2);
}
#[test]
fn tests_of_the_change_are_told_from_the_others() {
let changes = [
change("lib/booking/insights.ex", &[line_of("def list")]),
change("test/booking/report_test.exs", &[1]),
];
let radius = radius(project(&[]), &changes);
assert_eq!(radius.origins, [PathBuf::from("lib/booking/insights.ex")]);
let report = radius
.direct
.iter()
.find(|d| d.file.ends_with("report.ex"))
.unwrap();
assert_eq!(
(report.exposure, report.tests, report.tests_in_diff),
(Exposure::Calls, 1, 1)
);
}
#[test]
fn test_support_is_neither_a_test_nor_a_dependent() {
let changes = [change("lib/booking/export.ex", &[2])];
let radius = radius(project(&[]), &changes);
assert!(radius.direct.is_empty(), "{:?}", radius.direct);
assert_eq!(radius.reached(), 0);
}
#[test]
fn nothing_to_measure_without_a_changed_source() {
let changes = [change("README.md", &[1]), change("app/util.rb", &[2])];
let mut radius = radius(project(&[]), &changes);
assert!(radius.origins.is_empty());
assert_eq!(radius.functions, None);
assert!(radius.unavailable.is_empty());
let lines = render(&radius, 20);
assert_eq!(
lines[2],
" No changed source file in a language with a reliable graph"
);
radius.unavailable = vec![("Ruby".to_string(), 2)];
assert_eq!(
render(&radius, 20).last().unwrap(),
"Not measured for Ruby (2 changed): its graph does not reflect usage yet."
);
}
#[test]
fn a_go_change_reaches_every_importer_of_its_package() {
let sources = [
("go.mod", "module example.com/shop\n\ngo 1.22\n"),
("store/store.go", "package store\n\nfunc Open() {}\n"),
("store/index.go", "package store\n"),
("store/store_test.go", "package store\n"),
(
"api/handler.go",
"package api\n\nimport \"example.com/shop/store\"\n",
),
("api/handler_test.go", "package api\n"),
(
"jobs/sweep.go",
"package jobs\n\nimport (\n\t\"fmt\"\n\t\"example.com/shop/store\"\n)\n",
),
(
"cmd/shop/main.go",
"package main\n\nimport \"example.com/shop/api\"\n",
),
]
.iter()
.map(|(path, text)| (PathBuf::from(path), text.to_string()))
.collect();
let radius = radius(sources, &[change("store/index.go", &[1])]);
let mut files: Vec<&str> = radius
.direct
.iter()
.map(|d| d.file.to_str().unwrap())
.collect();
files.sort_unstable();
assert_eq!(files, ["api/handler.go", "jobs/sweep.go"]);
assert_eq!(radius.by_distance, [2, 1]);
assert_eq!(protection_of(&radius, "handler.go"), Protection::Direct);
assert_eq!(protection_of(&radius, "sweep.go"), Protection::None);
assert!(radius.unavailable.is_empty());
}
#[test]
fn render_when_the_change_is_narrowed_to_functions() {
let changes = [change(
"lib/booking/insights.ex",
&[line_of("Repo.insert!")],
)];
let lines = render(&radius(project(&[]), &changes), 20);
let sep = "─".repeat(78);
assert_eq!(
lines,
[
"Structural radius — source files that use what changed",
sep.as_str(),
" 1 file calls what changed, 1 of them with no test",
" Changed: lib/booking/insights.ex",
" Functions: arrange",
" Radius: 1 of 6 source files (17%): 1 at distance 1",
" Not in the radius: 1 that refer to the module without calling it",
" Upper bound, whatever the function: 4 files (67%)",
"",
" Tests Dependent",
" none lib/booking_web/controllers/insight_controller.ex",
" calls Insights.arrange",
" none lib/booking/export.ex",
" refers to the module without calling it",
sep.as_str(),
"No test reaches 1 of the files that call what changed; an integration test is probably missing:",
" lib/booking_web/controllers/insight_controller.ex",
"No test in the change exercises a file that uses what changed.",
"1 more with no test use the module without a call that tells whether the change concerns them.",
]
);
}
#[test]
fn render_when_the_change_cannot_be_narrowed() {
let changes = [change("lib/booking/insights.ex", &[line_of("defstruct")])];
let lines = render(&radius(project(&[CONTROLLER_TEST]), &changes), 2);
assert_eq!(
lines[2..6],
[
" 4 of 6 source files reached (67%), 3 direct, 1 of them with no test",
" Changed: lib/booking/insights.ex",
" Every use counts: lib/booking/insights.ex (line 4 is outside every function)",
" Reach: 3 at distance 1, 1 at distance 2",
]
);
assert_eq!(lines[8], " none lib/booking/export.ex");
assert!(
lines.contains(&" 3 files (2 shown).".to_string()),
"{lines:?}"
);
}
#[test]
fn render_when_nobody_calls_what_changed() {
let sources = project(&[]);
let changes = [change("lib/booking/mailer.ex", &[2])];
let lines = render(&radius(sources, &changes), 20);
assert_eq!(lines[2], " 0 of 6 source files reached.");
assert_eq!(lines[4], " No source file measured uses what changed.");
let unused = (
"lib/booking/repo.ex",
"defmodule Booking.Repo do\n def all(q), do: q\n def insert!(x), do: x\n def never(x), do: x\nend\n",
);
let mut sources = project(&[]);
sources.retain(|(path, _)| !path.ends_with("repo.ex"));
sources.push((PathBuf::from(unused.0), unused.1.to_string()));
let lines = render(&radius(sources, &[change("lib/booking/repo.ex", &[4])]), 20);
assert_eq!(
lines[2],
" 0 files call what changed, 0 of them with no test"
);
assert_eq!(lines[4], " Functions: never");
assert_eq!(lines[7], " No other file calls the functions that changed.");
}
#[test]
fn a_long_reach_is_summed_beyond_the_third_distance() {
let radius = Structural {
by_distance: vec![5, 4, 3, 2, 1],
..Structural::default()
};
assert_eq!(
reach_line(&radius),
"5 at distance 1, 4 at distance 2, 3 at distance 3, 3 further (up to distance 5)"
);
}
#[test]
fn what_each_file_is_to_the_radius() {
assert_eq!(role(Path::new("lib/app/user.ex")), Role::Source);
assert_eq!(role(Path::new("lib/mix/tasks/seed.ex")), Role::Source);
assert_eq!(role(Path::new("test/app/user_test.exs")), Role::Test);
assert_eq!(role(Path::new("src/ui/button.test.ts")), Role::Test);
assert_eq!(role(Path::new("test/support/factory.ex")), Role::Other);
assert_eq!(role(Path::new("test/test_helper.exs")), Role::Other);
assert_eq!(role(Path::new("config/runtime.exs")), Role::Other);
assert_eq!(role(Path::new("mix.exs")), Role::Other);
assert_eq!(role(Path::new("shop/tests.py")), Role::Test);
assert_eq!(role(Path::new("tests/billing/tests.py")), Role::Test);
assert_eq!(role(Path::new("shop/contests.py")), Role::Source);
assert_eq!(role(Path::new("conftest.py")), Role::Other);
assert_eq!(role(Path::new("src/shop/conftest.py")), Role::Other);
assert!(is_reliable(Path::new("lib/a.ex")));
assert!(is_reliable(Path::new("src/a.ts")));
assert!(is_reliable(Path::new("src/a.rs")));
assert!(is_reliable(Path::new("app/a.py")));
assert!(is_reliable(Path::new("app/a.go")));
for measured in ["config/a.exs", "web/a.js", "src/a.kai"] {
assert!(is_reliable(Path::new(measured)), "{measured}");
}
assert!(!is_reliable(Path::new("app/a.rb")));
assert!(!is_reliable(Path::new("README.md")));
}
#[test]
fn json_block() {
let changes = [change(
"lib/booking/insights.ex",
&[line_of("Repo.insert!")],
)];
let radius = radius(project(&[]), &changes);
let json = serde_json::to_value(JsonStructural::from(&radius)).unwrap();
assert_eq!(json["source_files"], 6);
assert_eq!(
json["origins"],
serde_json::json!(["lib/booking/insights.ex"])
);
assert_eq!(json["functions"], serde_json::json!(["arrange"]));
assert_eq!(json["reached"], 1);
assert_eq!(json["by_distance"], serde_json::json!([1]));
assert_eq!(json["upper_bound"], 4);
assert_eq!(
json["radius"],
serde_json::json!({"files": 1, "source_files": 6, "share": 0.1667})
);
assert_eq!(
json["reach"],
serde_json::json!([{
"distance": 1,
"files": ["lib/booking_web/controllers/insight_controller.ex"]
}])
);
assert_eq!(
json["unknown_without_tests"],
serde_json::json!(["lib/booking/export.ex"])
);
assert_eq!(json["change_tests_a_dependent"], false);
assert_eq!(
json["direct"][0],
serde_json::json!({
"file": "lib/booking_web/controllers/insight_controller.ex",
"exposure": "calls",
"protection": "none",
"calls": ["Insights.arrange"],
"tests": 0,
"tests_in_diff": 0,
"entry_point": false
})
);
assert_eq!(json["direct"][2]["exposure"], "elsewhere");
assert_eq!(
json["unprotected"],
serde_json::json!(["lib/booking_web/controllers/insight_controller.ex"])
);
let unnarrowed = Structural::default();
let json = serde_json::to_value(JsonStructural::from(&unnarrowed)).unwrap();
assert_eq!(json["functions"], serde_json::Value::Null);
}
#[test]
fn the_radius_carries_on_from_the_files_the_change_concerns() {
let router = (
"lib/booking_web/router.ex",
"defmodule BookingWeb.Router do\n def routes, do: [BookingWeb.InsightController]\nend\n",
);
let changes = [change(
"lib/booking/insights.ex",
&[line_of("Repo.insert!")],
)];
let radius = radius(project(&[router]), &changes);
assert_eq!(radius.by_distance, [1, 1]);
assert_eq!(radius.upper_bound, 5);
let lines = render(&radius, 20);
assert_eq!(
lines[5],
" Radius: 2 of 7 source files (29%): 1 at distance 1, 1 at distance 2"
);
assert_eq!(
lines[6],
" Not in the radius: 1 that refer to the module without calling it"
);
assert_eq!(
lines[7],
" Upper bound, whatever the function: 5 files (71%)"
);
}
#[test]
fn without_functions_the_radius_is_the_upper_bound() {
let changes = [change("lib/booking/insights.ex", &[line_of("defstruct")])];
let radius = radius(project(&[]), &changes);
assert_eq!(radius.by_distance, [3, 1]);
assert_eq!(radius.upper_bound, radius.reached());
}
#[test]
fn a_share_below_one_percent_is_not_shown_as_zero() {
let radius = Structural {
origins: vec![PathBuf::from("lib/a.ex")],
functions: Some(vec!["f".to_string()]),
source_files: 618,
direct: vec![Dependent {
file: PathBuf::from("lib/b.ex"),
exposure: Exposure::Calls,
protection: Protection::Direct,
calls: vec!["A.f".to_string()],
tests: 1,
tests_in_diff: 0,
entry_point: false,
used: false,
}],
by_distance: vec![1, 1],
upper_bound: 465,
..Structural::default()
};
let lines = summary(&radius);
assert_eq!(
lines[3],
" Radius: 2 of 618 source files (<1%): 1 at distance 1, 1 at distance 2"
);
assert_eq!(
lines[4],
" Upper bound, whatever the function: 465 files (75%)"
);
}
#[test]
fn each_changed_file_is_narrowed_on_its_own() {
let report_user = (
"lib/booking/digest.ex",
"defmodule Booking.Digest do\n def run, do: Booking.Report.run()\nend\n",
);
let mut added = change("lib/booking/mailer.ex", &[1, 2, 3]);
added.kind = ChangeKind::Added;
let changes = [
change(
"lib/booking/insights.ex",
&[line_of("record(insight, user, :arranged)")],
),
added,
change("lib/booking/report.ex", &[1]),
];
let radius = radius(project(&[report_user]), &changes);
assert_eq!(
radius.functions.as_deref(),
Some(&["arrange".to_string()][..])
);
let narrowing: Vec<(&str, Option<Vec<&str>>, Option<&str>)> = radius
.narrowing
.iter()
.map(|n| {
(
n.file.file_name().unwrap().to_str().unwrap(),
n.functions
.as_ref()
.map(|f| f.iter().map(String::as_str).collect()),
n.reason.as_deref(),
)
})
.collect();
assert_eq!(
narrowing,
[
("insights.ex", Some(vec!["arrange"]), None),
("mailer.ex", None, Some("new file")),
("report.ex", None, Some("line 1 is outside every function")),
]
);
let direct = direct(&radius);
assert!(direct.contains(&(
"insight_controller.ex",
Exposure::Calls,
0,
vec!["Insights.arrange"]
)));
assert!(direct.contains(&("digest.ex", Exposure::Calls, 0, vec!["Report.run"])));
let lines = render(&radius, 20);
assert_eq!(lines[4], " Functions: arrange");
assert_eq!(
lines[5],
" Every use counts for: lib/booking/mailer.ex (new file), lib/booking/report.ex (line 1 is outside every function)"
);
assert!(lines[6].starts_with(" Radius: "), "{}", lines[6]);
let json = serde_json::to_value(JsonStructural::from(&radius)).unwrap();
assert_eq!(
json["narrowing"][0],
serde_json::json!({"file": "lib/booking/insights.ex", "functions": ["arrange"], "reason": null})
);
assert_eq!(json["narrowing"][1]["reason"], "new file");
}
#[test]
fn a_language_without_functions_says_so() {
let sources = vec![
(
PathBuf::from("src/app.ts"),
"import { helper } from './util';\nhelper();\n".to_string(),
),
(
PathBuf::from("src/util.ts"),
"export const helper = () => 1;\n".to_string(),
),
];
let radius = radius(sources, &[change("src/util.ts", &[1])]);
assert_eq!(radius.functions, None);
assert_eq!(
radius.narrowing[0].reason.as_deref(),
Some("its language does not tell functions")
);
assert_eq!(direct(&radius), [("app.ts", Exposure::Calls, 0, vec![])]);
}
fn views() -> Vec<(PathBuf, String)> {
let files = [
(
"lib/shop/orders.ex",
"defmodule Shop.Orders do\n def list, do: []\nend\n",
),
(
"lib/shop_web/live/orders_live/index.ex",
"defmodule ShopWeb.OrdersLive.Index do\n def mount, do: {Shop.Orders.list(), ShopWeb.OrdersLive.Rows.build()}\nend\n",
),
(
"lib/shop_web/live/orders_live/rows.ex",
"defmodule ShopWeb.OrdersLive.Rows do\n def build, do: Shop.Orders.list()\nend\n",
),
(
"lib/shop_web/live/summary_live.ex",
"defmodule ShopWeb.SummaryLive do\n def mount, do: ShopWeb.Totals.sum()\nend\n",
),
(
"lib/shop_web/totals.ex",
"defmodule ShopWeb.Totals do\n def sum, do: Shop.Orders.list()\nend\n",
),
(
"lib/mix/tasks/orders.export.ex",
"defmodule Mix.Tasks.Orders.Export do\n def run(_), do: Shop.Orders.list()\nend\n",
),
(
"test/shop_web/live/orders_live_test.exs",
"defmodule ShopWeb.OrdersLiveTest do\n test \"lists\", %{conn: conn} do\n live(conn, \"/orders\")\n end\nend\n",
),
(
"test/shop_web/summary_live_test.exs",
"defmodule ShopWeb.SummaryLiveTest do\n test \"sums\", %{conn: conn} do\n live(conn, \"/summary\")\n end\nend\n",
),
];
files
.iter()
.map(|(path, text)| (PathBuf::from(path), text.to_string()))
.collect()
}
#[test]
fn protection_comes_in_degrees() {
let radius = radius(views(), &[change("lib/shop/orders.ex", &[2])]);
let protection: Vec<(&str, Protection, usize)> = radius
.direct
.iter()
.map(|d| {
(
d.file.file_name().unwrap().to_str().unwrap(),
d.protection,
d.tests,
)
})
.collect();
assert_eq!(
protection,
[
("orders.export.ex", Protection::None, 0),
("totals.ex", Protection::Users, 0),
("index.ex", Protection::Named, 0),
("rows.ex", Protection::Named, 0),
]
);
let unprotected: Vec<&Path> = radius.unprotected().map(|d| d.file.as_path()).collect();
assert_eq!(unprotected, [Path::new("lib/mix/tasks/orders.export.ex")]);
}
#[test]
fn render_tells_the_degrees_apart() {
let lines = render(&radius(views(), &[change("lib/shop/orders.ex", &[2])]), 20);
let table: Vec<&str> = lines
.iter()
.map(String::as_str)
.skip_while(|l| *l != " Tests Dependent")
.take_while(|l| !l.starts_with('─'))
.collect();
assert_eq!(
table,
[
" Tests Dependent",
" none lib/mix/tasks/orders.export.ex",
" calls Orders.list",
" users lib/shop_web/totals.ex",
" calls Orders.list",
" named lib/shop_web/live/orders_live/index.ex",
" calls Orders.list",
" named lib/shop_web/live/orders_live/rows.ex",
" calls Orders.list",
" named: a test carries its name; users: only what uses it is tested",
]
);
assert!(lines.contains(
&"No test reaches 1 of the files that call what changed; an integration test is probably missing:".to_string()
));
let direct_only = render(
&radius(
project(&[CONTROLLER_TEST]),
&[change(
"lib/booking/insights.ex",
&[line_of("record(insight, user, :arranged)")],
)],
),
20,
);
assert!(
!direct_only.iter().any(|l| l.contains("named: a test")),
"{direct_only:?}"
);
}
fn web() -> Vec<(PathBuf, String)> {
let files = [
(
"lib/shop/orders.ex",
"defmodule Shop.Orders do\n def list, do: []\nend\n",
),
(
"lib/shop_web/controllers/order_controller.ex",
"defmodule ShopWeb.OrderController do\n def index(conn, _), do: conn\nend\n",
),
(
"lib/shop_web/controllers/order_json.ex",
"defmodule ShopWeb.OrderJSON do\n def index(_), do: Shop.Orders.list()\nend\n",
),
(
"test/shop_web/controllers/order_controller_test.exs",
"defmodule ShopWeb.OrderControllerTest do\n test \"lists\", %{conn: conn} do\n get(conn, \"/orders\")\n end\nend\n",
),
(
"lib/shop_web/components/badge.ex",
"defmodule ShopWeb.Components.Badge do\n def count(_), do: Shop.Orders.list()\nend\n",
),
(
"lib/shop_web/live/summary_live.ex",
"defmodule ShopWeb.SummaryLive do\n alias ShopWeb.Components.Badge\nend\n",
),
(
"lib/shop_web/live/summary_live.html.heex",
"<h1>Summary</h1>\n<Badge.count orders={@orders} />\n",
),
(
"test/shop_web/live/summary_live_test.exs",
"defmodule ShopWeb.SummaryLiveTest do\n test \"sums\", %{conn: conn} do\n live(conn, \"/summary\")\n end\nend\n",
),
(
"lib/mix/tasks/orders.export.ex",
"defmodule Mix.Tasks.Orders.Export do\n def run(_), do: Shop.Orders.list()\nend\n",
),
];
files
.iter()
.map(|(path, text)| (PathBuf::from(path), text.to_string()))
.collect()
}
fn protection_of(radius: &Structural, name: &str) -> Protection {
let dependent = radius.direct.iter().find(|d| d.file.ends_with(name));
dependent
.unwrap_or_else(|| panic!("{name} is no dependent"))
.protection
}
#[test]
fn the_test_of_a_controller_protects_the_view_it_renders() {
let radius = radius(web(), &[change("lib/shop/orders.ex", &[2])]);
assert_eq!(protection_of(&radius, "order_json.ex"), Protection::Users);
}
#[test]
fn a_view_whose_controller_has_no_test_stays_unprotected() {
let untested: Vec<_> = web()
.into_iter()
.filter(|(path, _)| !path.ends_with("order_controller_test.exs"))
.collect();
let radius = radius(untested, &[change("lib/shop/orders.ex", &[2])]);
assert_eq!(protection_of(&radius, "order_json.ex"), Protection::None);
}
#[test]
fn a_component_is_used_by_the_module_whose_template_renders_it() {
let radius = radius(web(), &[change("lib/shop/orders.ex", &[2])]);
assert_eq!(protection_of(&radius, "badge.ex"), Protection::Users);
assert_eq!(radius.source_files, 6);
let without: Vec<_> = web()
.into_iter()
.filter(|(path, _)| !path.ends_with("summary_live.html.heex"))
.collect();
let radius = radius_of(without);
assert_eq!(protection_of(&radius, "badge.ex"), Protection::None);
}
fn radius_of(sources: Vec<(PathBuf, String)>) -> Structural {
radius(sources, &[change("lib/shop/orders.ex", &[2])])
}
#[test]
fn an_entry_point_with_no_test_is_told_apart() {
let mut radius = radius_of(web());
let unprotected =
|r: &Structural| -> Vec<PathBuf> { r.unprotected().map(|d| d.file.clone()).collect() };
let task = PathBuf::from("lib/mix/tasks/orders.export.ex");
assert_eq!(unprotected(&radius), [task.clone()]);
radius.mark_entry_points(|path| path.starts_with("lib/mix/tasks"), |_| false);
assert!(unprotected(&radius).is_empty());
let entry: Vec<&PathBuf> = radius.untested_entry_points().map(|d| &d.file).collect();
assert_eq!(entry, [&task]);
let text = render(&radius, 10).join("\n");
assert!(
text.contains("Run rather than used, and with no test, 1: lib/mix/tasks/orders.export.ex"),
"{text}"
);
assert!(
!text.contains("an integration test is probably missing"),
"{text}"
);
let json = serde_json::to_value(JsonStructural::from(&radius)).unwrap();
assert_eq!(
json["untested_entry_points"][0],
"lib/mix/tasks/orders.export.ex"
);
assert_eq!(json["unprotected"].as_array().unwrap().len(), 0);
}
fn routed() -> Vec<(PathBuf, String)> {
let files = [
(
"lib/shop/orders.ex",
"defmodule Shop.Orders do\n def list, do: []\nend\n",
),
(
"lib/shop_web/router.ex",
"defmodule ShopWeb.Router do\n use ShopWeb, :router\n\n scope \"/\", ShopWeb do\n live \"/orders\", OrdersLive.Index, :index\n live \"/returns\", ReturnsLive.Index, :index\n end\nend\n",
),
(
"lib/shop_web/live/orders_live/index.ex",
"defmodule ShopWeb.OrdersLive.Index do\n def mount, do: Shop.Orders.list()\nend\n",
),
(
"lib/shop_web/live/returns_live/index.ex",
"defmodule ShopWeb.ReturnsLive.Index do\n def mount, do: Shop.Orders.list()\nend\n",
),
(
"test/shop_web/features/checkout_test.exs",
"defmodule ShopWeb.CheckoutTest do\n test \"lists\", %{conn: conn} do\n {:ok, _, _} = live(conn, ~p\"/orders\")\n end\nend\n",
),
];
files
.iter()
.map(|(path, text)| (PathBuf::from(path), text.to_string()))
.collect()
}
#[test]
fn a_test_that_requests_a_route_protects_the_view_that_serves_it() {
let radius = radius_of(routed());
assert_eq!(
protection_of(&radius, "orders_live/index.ex"),
Protection::Direct
);
assert_eq!(
protection_of(&radius, "returns_live/index.ex"),
Protection::None
);
assert_eq!(radius.direct.len(), 2);
}
#[test]
fn a_test_asks_the_router_of_its_own_project() {
let other: Vec<(PathBuf, String)> = routed()
.into_iter()
.map(|(path, text)| match path.starts_with("test") {
true => (PathBuf::from("other").join(path), text),
false => (PathBuf::from("shop").join(path), text),
})
.chain([(
PathBuf::from("other/lib/other_web/router.ex"),
"defmodule OtherWeb.Router do\n use OtherWeb, :router\n live \"/orders\", OtherWeb.OrdersLive\nend\n".to_string(),
), (
PathBuf::from("other/lib/other_web/orders_live.ex"),
"defmodule OtherWeb.OrdersLive do\nend\n".to_string(),
)])
.collect();
let radius = radius(other, &[change("shop/lib/shop/orders.ex", &[2])]);
assert_eq!(
protection_of(&radius, "orders_live/index.ex"),
Protection::None
);
}
#[test]
fn a_file_at_a_conventional_place_that_is_used_is_no_entry_point() {
let mut sources = web();
sources.push((
PathBuf::from("lib/shop/nightly.ex"),
"defmodule Shop.Nightly do\n def run, do: Mix.Tasks.Orders.Export.run([])\nend\n"
.to_string(),
));
let mut radius = radius_of(sources);
let in_tasks = |path: &Path| path.starts_with("lib/mix/tasks");
radius.mark_entry_points(in_tasks, |_| false);
assert_eq!(radius.untested_entry_points().count(), 0);
assert_eq!(radius.unprotected().count(), 1);
radius.mark_entry_points(|_| false, in_tasks);
assert_eq!(radius.untested_entry_points().count(), 1);
}
fn crate_with_tests() -> Vec<(PathBuf, String)> {
[
("shop/Cargo.toml", "[package]\nname = \"my-shop\"\nversion = \"0.1.0\"\n"),
("shop/src/lib.rs", "pub mod orders;\npub mod cart;\npub mod receipt;\npub mod report;\npub mod stock;\n"),
("shop/src/stock.rs", "pub fn left() -> u32 { crate::orders::total() }\n#[cfg(test)]\nmod tests;\n"),
("shop/src/stock/tests.rs", "use super::*;\n#[test]\nfn some() { assert_eq!(left(), 1) }\n"),
("shop/src/orders.rs", "pub fn total() -> u32 { 1 }\n"),
(
"shop/src/cart.rs",
"use crate::orders;\npub fn summary() -> u32 { orders::total() }\n#[cfg(test)]\nmod tests {\n use super::*;\n #[test]\n fn sums() { assert_eq!(summary(), 1) }\n}\n",
),
("shop/src/receipt.rs", "pub fn lines() -> u32 { crate::orders::total() }\n"),
("shop/src/report.rs", "use super::orders::total;\npub fn double() -> u32 { total() * 2 }\n"),
(
"shop/tests/receipt.rs",
"mod common;\nuse my_shop::receipt::lines;\n#[test]\nfn one() { assert_eq!(lines(), 1) }\n",
),
("shop/tests/common/mod.rs", "pub fn setup() -> u32 { my_shop::report::double() }\n"),
]
.iter()
.map(|(path, text)| (PathBuf::from(path), text.to_string()))
.collect()
}
#[test]
fn rust_tests_kept_beside_the_code_and_under_tests_protect() {
let radius = radius(crate_with_tests(), &[change("shop/src/orders.rs", &[1])]);
let files: Vec<&str> = radius
.direct
.iter()
.map(|d| d.file.to_str().unwrap())
.collect();
assert_eq!(
files,
[
"shop/src/report.rs",
"shop/src/cart.rs",
"shop/src/receipt.rs",
"shop/src/stock.rs"
]
);
assert_eq!(protection_of(&radius, "cart.rs"), Protection::Direct);
assert_eq!(protection_of(&radius, "receipt.rs"), Protection::Direct);
assert_eq!(protection_of(&radius, "stock.rs"), Protection::Direct);
assert_eq!(protection_of(&radius, "report.rs"), Protection::None);
assert!(radius.unavailable.is_empty());
}
fn python_package_with_tests() -> Vec<(PathBuf, String)> {
[
("src/shop/__init__.py", ""),
("src/shop/orders.py", "def total():\n return 1\n"),
("src/shop/cart.py", "from shop.orders import total\n"),
(
"src/shop/receipt.py",
"from . import orders\n\nTOTAL = orders.total()\n",
),
(
"src/shop/stock.py",
"import shop.orders\n\nTOTAL = shop.orders.total()\n",
),
("src/shop/report.py", "from .cart import summary\n"),
("tests/test_cart.py", "from shop.cart import summary\n"),
("tests/billing/tests.py", "from shop import receipt\n"),
("tests/conftest.py", "import shop.stock\n"),
]
.iter()
.map(|(path, text)| (PathBuf::from(path), text.to_string()))
.collect()
}
#[test]
fn python_absolute_and_relative_imports_carry_the_radius() {
let changes = [change("src/shop/orders.py", &[1])];
let radius = radius(python_package_with_tests(), &changes);
let mut files: Vec<&str> = radius
.direct
.iter()
.map(|d| d.file.to_str().unwrap())
.collect();
files.sort_unstable();
assert_eq!(
files,
[
"src/shop/cart.py",
"src/shop/receipt.py",
"src/shop/stock.py"
]
);
assert_eq!(radius.by_distance, [3, 1]);
assert_eq!(protection_of(&radius, "cart.py"), Protection::Direct);
assert_eq!(protection_of(&radius, "receipt.py"), Protection::Direct);
assert_eq!(protection_of(&radius, "stock.py"), Protection::None);
assert!(radius.unavailable.is_empty());
}
fn python_shop() -> Vec<(PathBuf, String)> {
[
(
"shop/prices.py",
"def tax(x):\n return x\n\n\ndef total(x):\n return x\n\n\nsetup()\n",
),
("shop/cart.py", "from shop.prices import tax\n"),
("shop/receipt.py", "from shop.prices import total\n"),
("shop/stock.py", "import shop.prices\n"),
("shop/web.py", "from shop.cart import add\n"),
("shop/print.py", "from shop.receipt import show\n"),
]
.iter()
.map(|(path, text)| (PathBuf::from(path), text.to_string()))
.collect()
}
#[test]
fn a_python_change_is_narrowed_to_the_names_it_touches() {
let radius = radius(python_shop(), &[change("shop/prices.py", &[2])]);
assert_eq!(radius.functions, Some(vec!["tax".to_string()]));
assert_eq!(
direct(&radius),
[
("cart.py", Exposure::Calls, 0, vec!["prices.tax"]),
("stock.py", Exposure::Refers, 0, vec![]),
("receipt.py", Exposure::Elsewhere, 0, vec![]),
]
);
assert_eq!(radius.by_distance, [1, 1]);
assert_eq!(radius.upper_bound, 5);
}
#[test]
fn python_code_that_runs_on_import_counts_every_use() {
let radius = radius(python_shop(), &[change("shop/prices.py", &[9])]);
assert_eq!(radius.functions, None);
assert_eq!(
radius.narrowing[0].reason.as_deref(),
Some("line 9 is outside every function")
);
assert_eq!(radius.by_distance, [3, 2]);
}
#[test]
fn a_change_without_known_lines_says_so() {
let radius = radius(python_shop(), &[change("shop/prices.py", &[])]);
assert_eq!(radius.functions, None);
assert_eq!(
radius.narrowing[0].reason.as_deref(),
Some("no changed line is known")
);
}