Initial Dever 0.0.1
This commit is contained in:
commit
81233d098c
1479 files changed
+163782
No files matched your search
@@ -0,0 +1,235 @@
|
||||
use super::Project;
|
||||
use std::{collections::BTreeSet, path::PathBuf};
|
||||
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../test/rust/fixtures")
|
||||
.join(name)
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn charts(page: &str) -> impl Iterator<Item = &str> {
|
||||
page.split("```mermaid\n")
|
||||
.skip(1)
|
||||
.map(|chart| chart.split("```").next().unwrap())
|
||||
}
|
||||
|
||||
fn node(chart: &str, text: &str) -> String {
|
||||
chart
|
||||
.lines()
|
||||
.find(|line| line.contains(text) && !line.contains(" -->") && !line.contains("click "))
|
||||
.unwrap_or_else(|| panic!("missing {text}:\n{chart}"))
|
||||
.trim()
|
||||
.split(['[', '{', '('])
|
||||
.next()
|
||||
.unwrap()
|
||||
.into()
|
||||
}
|
||||
|
||||
// 按图的边检查可达性,不能把节点声明顺序误当执行顺序。
|
||||
fn reaches(chart: &str, from: &str, to: &str) -> bool {
|
||||
let mut pending = vec![from.to_owned()];
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(current) = pending.pop() {
|
||||
if current == to {
|
||||
return true;
|
||||
}
|
||||
if !seen.insert(current.clone()) {
|
||||
continue;
|
||||
}
|
||||
for line in chart.lines() {
|
||||
if line.trim().starts_with(&format!("{current} -->")) {
|
||||
pending.push(line.split_whitespace().last().unwrap().to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_flows_follow_authentication_permission_decode_call_and_transaction_order() {
|
||||
let root = fixture("auth");
|
||||
let project = Project::load(&root).unwrap();
|
||||
let index = project.inspect().unwrap();
|
||||
let pages = index.links(&root).unwrap();
|
||||
let page = &pages[&root.join("module/demo/account/link.md")];
|
||||
let program = project.check().unwrap();
|
||||
let rust = dever::compiler::emit::application::generate(&program);
|
||||
for (position, entry) in program.http.iter().enumerate() {
|
||||
if !entry.path.starts_with("/demo/account/") {
|
||||
continue;
|
||||
}
|
||||
let chart = charts(page)
|
||||
.find(|chart| chart.contains(&format!("路由 {} {},", entry.method, entry.path)))
|
||||
.unwrap();
|
||||
let decode = node(chart, "解码具体参数");
|
||||
let call = node(
|
||||
chart,
|
||||
&format!(
|
||||
"[[\"demo.account.{}\"",
|
||||
program.functions[entry.function].signature.name
|
||||
),
|
||||
);
|
||||
assert!(reaches(chart, &decode, &call));
|
||||
assert!(!reaches(chart, &node(chart, "失败:保留 HTTP"), &call));
|
||||
assert!(chart.contains("demo.account.verify"));
|
||||
let emitted = rust
|
||||
.split(&format!("async fn api{position}("))
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("struct HttpArgs")
|
||||
.next()
|
||||
.unwrap();
|
||||
assert!(
|
||||
emitted.find("authentication.prepare_at").unwrap()
|
||||
< emitted.find("let args: HttpArgs").unwrap()
|
||||
);
|
||||
match entry.access {
|
||||
dever::compiler::model::auth::Access::Protected => {
|
||||
let permission = node(chart, "权限检查:");
|
||||
assert!(reaches(chart, &permission, &decode));
|
||||
assert!(!reaches(chart, &decode, &permission));
|
||||
assert!(
|
||||
emitted.find("authorization::authorize_entry").unwrap()
|
||||
< emitted.find("let args: HttpArgs").unwrap()
|
||||
);
|
||||
}
|
||||
dever::compiler::model::auth::Access::Public => {
|
||||
assert!(chart.contains("public 忽略自动 Cookie"));
|
||||
assert!(chart.contains("没有显式凭据:匿名"));
|
||||
assert!(!chart.contains("权限检查:"));
|
||||
}
|
||||
dever::compiler::model::auth::Access::Authenticated => {
|
||||
assert!(!chart.contains("没有显式凭据:匿名"));
|
||||
assert!(!chart.contains("权限检查:"));
|
||||
}
|
||||
}
|
||||
let write = program.functions[entry.function]
|
||||
.signature
|
||||
.effects
|
||||
.database_write
|
||||
&& !entry.long;
|
||||
assert_eq!(chart.contains("进入写入口事务"), write);
|
||||
if write {
|
||||
assert!(reaches(chart, &call, &node(chart, "提交入口拥有的事务")));
|
||||
assert!(reaches(
|
||||
chart,
|
||||
&node(chart, "失败:保留 HTTP"),
|
||||
&node(chart, "回滚入口拥有的事务")
|
||||
));
|
||||
}
|
||||
}
|
||||
let anchors: Vec<_> = page
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("<a id="))
|
||||
.collect();
|
||||
assert_eq!(anchors.len(), anchors.iter().collect::<BTreeSet<_>>().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_flows_link_actual_field_functions_owner_model_and_failure_exits() {
|
||||
let root = fixture("auth");
|
||||
let project = Project::load(&root).unwrap();
|
||||
let index = project.inspect().unwrap();
|
||||
let pages = index.links(&root).unwrap();
|
||||
let page = &pages[&root.join("module/demo/account/link.md")];
|
||||
let chart = charts(page)
|
||||
.find(|chart| chart.contains("自动 REST /demo/account/admin/profile"))
|
||||
.unwrap();
|
||||
for label in [
|
||||
"405 + Allow",
|
||||
"Owner",
|
||||
"COUNT + 分页 SELECT",
|
||||
"SELECT 单条",
|
||||
"INSERT RETURNING",
|
||||
"UPDATE RETURNING",
|
||||
"DELETE",
|
||||
"提交入口拥有的事务",
|
||||
"回滚入口拥有的事务",
|
||||
] {
|
||||
assert!(chart.contains(label), "{label}");
|
||||
}
|
||||
let create = node(chart, "model.profile.#rest_create_note");
|
||||
let replace = node(chart, "model.profile.#rest_replace_note");
|
||||
assert!(!reaches(chart, &create, &replace));
|
||||
assert!(!reaches(chart, &replace, &create));
|
||||
assert!(reaches(chart, &create, &node(chart, "INSERT RETURNING")));
|
||||
assert!(!reaches(chart, &node(chart, "失败:保留 HTTP"), &create));
|
||||
let normalize = node(chart, "规范化字段 name:去首尾空白");
|
||||
let validate = chart
|
||||
.lines()
|
||||
.filter(|line| line.contains("检查字段 name 的长度 / 有限数合同"))
|
||||
.map(|line| line.trim().split('[').next().unwrap())
|
||||
.find(|node| reaches(chart, &normalize, node))
|
||||
.unwrap();
|
||||
assert!(reaches(chart, validate, &node(chart, "INSERT RETURNING")));
|
||||
assert!(!reaches(chart, &normalize, &node(chart, "组装分页查询")));
|
||||
assert!(chart.contains("model/profile.dever#L"));
|
||||
assert!(index.calls.iter().any(|call| call.kind == "rest-field"));
|
||||
assert!(index.calls.iter().any(|call| call.kind == "rest-model"));
|
||||
for call in index.calls.iter().filter(|call| call.kind == "rest-field") {
|
||||
let symbol = &index.symbols[call.callee];
|
||||
assert!(!symbol.declarations.is_empty());
|
||||
assert!(
|
||||
index.calls.iter().any(|next| next.caller == symbol.id),
|
||||
"missing field expression calls: {}",
|
||||
symbol.name
|
||||
);
|
||||
}
|
||||
let program = project.check().unwrap();
|
||||
let rust = dever::compiler::emit::application::generate(&program);
|
||||
let rest = rust
|
||||
.split("async fn rest0(")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("struct RestPage1")
|
||||
.next()
|
||||
.unwrap();
|
||||
assert!(
|
||||
rest.find("authorization::authorize_entry").unwrap()
|
||||
< rest.find("let executor = database_context.on").unwrap()
|
||||
);
|
||||
assert!(rest.contains("database_context.finish(scope,owner,outcome.is_ok())"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_flows_have_exclusive_real_targets_and_setting_source_locations() {
|
||||
let root = fixture("adapters");
|
||||
let index = Project::load(&root).unwrap().inspect().unwrap();
|
||||
let pages = index.links(&root).unwrap();
|
||||
let page = &pages[&root.join("module/demo/value/link.md")];
|
||||
let chart = charts(page)
|
||||
.find(|chart| {
|
||||
chart.contains("demo.value.adapter.local.port.calculate") && chart.contains("启动配置")
|
||||
})
|
||||
.unwrap();
|
||||
let local = node(chart, "demo.value.adapter.local.port.calculate");
|
||||
let remote = node(chart, "demo.value.adapter.remote.port.calculate");
|
||||
assert!(!reaches(chart, &local, &remote));
|
||||
assert!(!reaches(chart, &remote, &local));
|
||||
assert!(index.calls.iter().any(|call| call.kind == "adapter"
|
||||
&& index.symbols[call.callee].name == "demo.value.adapter.remote.port.calculate"));
|
||||
assert!(page.contains("配置字段(不显示值)"));
|
||||
assert!(page.contains("adapter/local.dever#L"));
|
||||
assert!(!page.contains("未展开"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_adapters_end_at_declared_command_or_worker_boundaries() {
|
||||
let examples = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../skills/assets/examples")
|
||||
.canonicalize()
|
||||
.unwrap();
|
||||
for (name, label) in [("command", "外部命令边界"), ("external-lib", "Worker 边界")] {
|
||||
let root = examples.join(name);
|
||||
let pages = Project::load(&root)
|
||||
.unwrap()
|
||||
.inspect()
|
||||
.unwrap()
|
||||
.links(&root)
|
||||
.unwrap();
|
||||
assert!(pages.values().any(|page| page.contains(label)));
|
||||
assert!(pages.values().all(|page| !page.contains("未展开")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn documentation_heading_positions_preserve_unicode_fences_and_crlf() {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("app");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
let path = root.join("module/hello/greeting/doc/greeting.md");
|
||||
let text = "# 中文说明\r\n\r\n```markdown\r\n## not.a.symbol\r\n```\r\n\r\n## hello.greeting.greet\r\n\r\n真实说明。\r\n";
|
||||
std::fs::write(path, text).unwrap();
|
||||
let index = Project::load(&root).unwrap().inspect().unwrap();
|
||||
assert!(index.documentation_errors.is_empty());
|
||||
let description = index
|
||||
.documentation
|
||||
.iter()
|
||||
.find(|description| description.symbol == "hello.greeting.greet")
|
||||
.unwrap();
|
||||
assert_eq!(description.line, 6);
|
||||
assert_eq!(description.markdown, "真实说明。");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documentation_binds_clauses_without_entering_program_semantics() {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("app");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
let path = root.join("module/hello/greeting/doc/greeting.md");
|
||||
let original = std::fs::read_to_string(&path).unwrap();
|
||||
let project = Project::load(&root).unwrap();
|
||||
let before = project.api_snapshot().unwrap();
|
||||
let index = project.inspect().unwrap();
|
||||
assert!(
|
||||
index.documentation_errors.is_empty(),
|
||||
"{:?}",
|
||||
index.documentation_errors
|
||||
);
|
||||
let symbol = index
|
||||
.symbols
|
||||
.iter()
|
||||
.find(|symbol| symbol.name == "hello.greeting.greet")
|
||||
.unwrap();
|
||||
assert_eq!(symbol.signatures, ["greet(name: Text) (message: Text)"]);
|
||||
let description = index
|
||||
.documentation
|
||||
.iter()
|
||||
.find(|doc| doc.symbol == symbol.name)
|
||||
.unwrap();
|
||||
assert!(description.markdown.contains("按原样保留"));
|
||||
assert_eq!(
|
||||
index.documentation_files[description.file].path,
|
||||
path.to_string_lossy()
|
||||
);
|
||||
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!("{original}\n```markdown\n## not.a.symbol\n```\n\n> ## quoted.symbol\n\n- Nested\n\n ## nested.symbol\n"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(project.inspect().unwrap().documentation_errors.is_empty());
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!("{original}\n## hello.greeting.missing\n\n过期说明。\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let changed = project.inspect().unwrap();
|
||||
assert!(
|
||||
changed
|
||||
.documentation_errors
|
||||
.iter()
|
||||
.any(|error| error.contains("hello.greeting.missing"))
|
||||
);
|
||||
assert_eq!(project.api_snapshot().unwrap(), before);
|
||||
project.check().unwrap();
|
||||
|
||||
std::fs::write(&path, original).unwrap();
|
||||
std::fs::write(
|
||||
root.join("module/hello/greeting/doc/duplicate.md"),
|
||||
"## hello.greeting.greet\n\n重复。\n",
|
||||
)
|
||||
.unwrap();
|
||||
let duplicate = project.inspect().unwrap();
|
||||
assert!(
|
||||
duplicate
|
||||
.documentation_errors
|
||||
.iter()
|
||||
.any(|error| error.contains("duplicate documentation"))
|
||||
);
|
||||
assert!(
|
||||
!duplicate
|
||||
.documentation
|
||||
.iter()
|
||||
.any(|doc| doc.symbol == "hello.greeting.greet")
|
||||
);
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.args(["inspect", root.to_str().unwrap(), "--links", "--check"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("duplicate documentation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_source_format_is_rejected_instead_of_silently_skipped() {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("app");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
let path = root.join("module/hello/greeting/domain.dever.md");
|
||||
std::fs::write(&path, "# Old source\n\n```dever\nunused() () {}\n```\n").unwrap();
|
||||
assert!(Project::load(&root).err().unwrap().contains("doc/"));
|
||||
assert!(Project::load(&path).err().unwrap().contains("project root"));
|
||||
assert!(
|
||||
dever::compiler::format::project(&root, true)
|
||||
.unwrap_err()
|
||||
.contains("doc/")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use super::{Directory, Project};
|
||||
use std::fs;
|
||||
|
||||
fn page(source: &str) -> String {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("flow project");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
fs::write(root.join("module/hello/greeting/app.dever"), source).unwrap();
|
||||
let pages = Project::load(&root)
|
||||
.unwrap()
|
||||
.inspect()
|
||||
.unwrap()
|
||||
.links(&root)
|
||||
.unwrap();
|
||||
pages[&root.join("module/hello/greeting/link.md")].clone()
|
||||
}
|
||||
|
||||
fn chart<'a>(page: &'a str, name: &str) -> &'a str {
|
||||
page.split(&format!("### hello.greeting.{name}\n"))
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("```mermaid\n")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("```")
|
||||
.next()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn node(chart: &str, label: &str) -> String {
|
||||
chart
|
||||
.lines()
|
||||
.find(|line| line.contains(&format!("\"{label}\"")))
|
||||
.unwrap_or_else(|| panic!("missing node {label}:\n{chart}"))
|
||||
.trim()
|
||||
.split(['[', '{', '('])
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flows_evaluate_nested_arguments_before_calls_and_keep_clause_branches_separate() {
|
||||
let page = page(
|
||||
"greet(name: Text) (message: Text) { message = choose(present(name), decorate(clean(name))) }\npresent(name: Text) (value: Bool) { value = name != \"\" }\nclean(name: Text) (value: Text) { value = name + \"!\" }\ndecorate(name: Text) (value: Text) { value = \"[\" + name + \"]\" }\nchoose(value: true, name: Text) (message: Text) { message = name }\nchoose(value: other, name: Text) (message: Text) { message = \"empty\" }\n",
|
||||
);
|
||||
let main = chart(&page, "greet");
|
||||
let calls: Vec<_> = ["present", "clean", "decorate", "choose"]
|
||||
.iter()
|
||||
.map(|name| node(main, &format!("hello.greeting.{name}")))
|
||||
.collect();
|
||||
for pair in calls.windows(2) {
|
||||
assert!(main.contains(&format!("{} --> {}", pair[0], pair[1])));
|
||||
}
|
||||
let branch = chart(&page, "choose");
|
||||
assert!(branch.contains("按实参匹配分句(互斥)"));
|
||||
let positive = node(branch, "value: true");
|
||||
let negative = node(branch, "value: other");
|
||||
assert!(!branch.contains(&format!("{positive} --> {negative}")));
|
||||
assert!(!branch.contains(&format!("{negative} --> {positive}")));
|
||||
assert!(page.contains("[hello.greeting.choose](link.md#flow-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flows_short_circuit_and_task_launch_do_not_turn_into_unconditional_calls() {
|
||||
let page = page(
|
||||
"greet(name: Text) (message: Text) {\n enabled = present(name) and allowed(name)\n task = run(label(enabled))\n message = wait(task)\n}\npresent(name: Text) (value: Bool) { value = name != \"\" }\nallowed(name: Text) (value: Bool) { value = name != \"blocked\" }\nlabel(value: true) (message: Text) { message = \"yes\" }\nlabel(value: other) (message: Text) { message = \"no\" }\n",
|
||||
);
|
||||
let main = chart(&page, "greet");
|
||||
let condition = node(main, "present(name)");
|
||||
let evaluate = node(main, "求右侧值");
|
||||
let allowed = node(main, "hello.greeting.allowed");
|
||||
assert!(main.contains(&format!("{condition} -->|\"true\"| {evaluate}")));
|
||||
assert!(main.contains(&format!("{evaluate} --> {allowed}")));
|
||||
assert!(main.contains("false:跳过右侧"));
|
||||
let launch = node(main, "启动任务");
|
||||
let child = node(main, "hello.greeting.label");
|
||||
assert!(main.contains(&format!("{launch} -.->|\"子任务独立执行\"| {child}")));
|
||||
assert!(main.contains("返回 Task,父流程继续"));
|
||||
assert!(main.contains("等待任务结果:task"));
|
||||
assert!(!main.contains(&format!("{launch} --> {child}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flows_collection_has_empty_path_and_repeated_callback_instead_of_one_call() {
|
||||
let page = page(
|
||||
"greet(name: Text) (message: Text) {\n values = each(label, [true, false])\n message = name + \"!\"\n}\nlabel(value: true) (text: Text) { text = \"yes\" }\nlabel(value: other) (text: Text) { text = \"no\" }\n",
|
||||
);
|
||||
let main = chart(&page, "greet");
|
||||
let condition = node(main, "还有元素?");
|
||||
let callback = node(main, "hello.greeting.label");
|
||||
assert!(main.contains("否(包括空集合)"));
|
||||
assert!(main.contains(&format!("{callback} -->|\"下一元素\"| {condition}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flows_timeout_only_calls_fallback_after_cancellation_and_explicit_stop_waits() {
|
||||
let page = page(
|
||||
"greet(name: Text) (message: Text) {\n abandoned = run(pause())\n stop(abandoned)\n message = timeout(run(work(name)), 1, fallback)\n}\npause() () { dever.task.sleep(1) }\nwork(name: Text) (message: Text) {\n dever.task.sleep(1)\n message = name\n}\nfallback() (message: Text) { message = \"expired\" }\n",
|
||||
);
|
||||
let main = chart(&page, "greet");
|
||||
let wait = node(main, "限时等待任务");
|
||||
let cancel = node(main, "取消并等待任务退出");
|
||||
let fallback = node(main, "hello.greeting.fallback");
|
||||
assert!(main.contains(&format!("{wait} -->|\"超时\"| {cancel}")));
|
||||
assert!(main.contains(&format!("{cancel} --> {fallback}")));
|
||||
assert!(!main.contains(&format!("{wait} --> {fallback}")));
|
||||
assert!(main.contains("请求取消并等待退出:abandoned"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flows_capture_distinguishes_business_failure_from_fault_and_fail_has_no_success_exit() {
|
||||
let page = page(
|
||||
"type Outcome { Done(value: Text)\n error Failed(message: Text) }\ngreet(name: Text) (message: Bool) recover(\"observe captured failure\") {\n outcome = capture(name)\n message = outcome == Outcome.Failed(name)\n}\ncapture(name: Text) (message: Outcome) { message = result(bad(name)) }\nbad(name: Text) (value: Text) { fail(Outcome.Failed(name)) }\n",
|
||||
);
|
||||
let main = chart(&page, "capture");
|
||||
assert!(main.contains("声明的业务失败"));
|
||||
assert!(main.contains("Fault(不转换为业务失败)"));
|
||||
let failed = chart(&page, "bad");
|
||||
assert!(failed.contains("传播错误"));
|
||||
assert!(!failed.contains("\"返回\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flows_and_navigation_keep_direct_typed_decode_capture() {
|
||||
let source = "type Outcome { Done(value: Text)\n error Failed(cause: dever.json.JsonResult) }\ngreet(name: Text) (message: Text) { payload: Json = dever.json.encode(name)\n value: Outcome = result(dever.json.decode(payload))\n message = render(value) }\nrender(value: Outcome.Done(text)) (message: Text) recover(\"显示无效输入\") { message = text }\nrender(value: Outcome.Failed(cause)) (message: Text) recover(\"显示无效输入\") { message = \"invalid\" }\n";
|
||||
let page = page(source);
|
||||
let flow = chart(&page, "greet");
|
||||
assert!(flow.contains("类型化 JSON 解码"));
|
||||
assert!(flow.contains("声明的业务失败"));
|
||||
let directory = Directory::new();
|
||||
let file = directory.0.join("main.dever");
|
||||
fs::write(&file, source).unwrap();
|
||||
let index = Project::load(&file).unwrap().inspect().unwrap();
|
||||
let decode = index
|
||||
.symbols
|
||||
.iter()
|
||||
.find(|symbol| symbol.name == "dever.json.decode")
|
||||
.unwrap();
|
||||
let calls = index
|
||||
.calls
|
||||
.iter()
|
||||
.filter(|call| call.callee == decode.id)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].kind, "operation");
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
use super::{Command, Directory, Project};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn inspect(root: &Path, check: bool) -> std::process::Output {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_dever"));
|
||||
command.arg("inspect").arg(root).arg("--links");
|
||||
if check {
|
||||
command.arg("--check");
|
||||
}
|
||||
command.output().unwrap()
|
||||
}
|
||||
|
||||
fn success(output: std::process::Output) -> String {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_links_check_is_read_only_and_generation_is_stable() {
|
||||
{
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("project with spaces");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
let link = root.join("module/hello/greeting/link.md");
|
||||
fs::remove_file(&link).unwrap();
|
||||
let missing = inspect(&root, true);
|
||||
assert_eq!(missing.status.code(), Some(1));
|
||||
assert!(!link.exists());
|
||||
assert!(success(inspect(&root, false)).contains("1 changed"));
|
||||
let first = fs::read_to_string(&link).unwrap();
|
||||
assert!(first.contains("CMD [hello.greeting.api.greet]"));
|
||||
assert!(!first.contains(root.to_str().unwrap()));
|
||||
assert!(first.contains("app.dever#L"));
|
||||
let modified = fs::metadata(&link).unwrap().modified().unwrap();
|
||||
assert!(success(inspect(&root, false)).contains("0 changed"));
|
||||
assert_eq!(fs::metadata(&link).unwrap().modified().unwrap(), modified);
|
||||
success(inspect(&root, true));
|
||||
|
||||
let source = root.join("module/hello/greeting/app.dever");
|
||||
let text = fs::read_to_string(&source).unwrap();
|
||||
fs::write(&source, text.replace("Hello, ", "Welcome, ")).unwrap();
|
||||
assert_eq!(inspect(&root, true).status.code(), Some(1));
|
||||
assert_eq!(fs::read_to_string(&link).unwrap(), first);
|
||||
success(inspect(&root, false));
|
||||
assert_ne!(fs::read_to_string(&link).unwrap(), first);
|
||||
success(inspect(&root, true));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_links_keep_every_cross_domain_call_site_and_refresh_both_ends() {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("project");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
for domain in ["checkout", "account"] {
|
||||
let owner = root.join("module/shop").join(domain);
|
||||
fs::create_dir_all(&owner).unwrap();
|
||||
fs::write(owner.join("app.dever"), "welcome(name: Text) (message: Text) {\n message = hello.greeting.greet(name) + hello.greeting.greet(\"other\")\n}\n").unwrap();
|
||||
fs::write(owner.join("api.dever"), "cmd welcome = app.welcome\n").unwrap();
|
||||
}
|
||||
let unrelated = root.join("module/empty/types");
|
||||
fs::create_dir_all(&unrelated).unwrap();
|
||||
fs::write(
|
||||
unrelated.join("domain.dever"),
|
||||
"type Label { value: Text }\n",
|
||||
)
|
||||
.unwrap();
|
||||
success(inspect(&root, false));
|
||||
let incoming = root.join("module/hello/greeting/link.md");
|
||||
let first = fs::read_to_string(&incoming).unwrap();
|
||||
let incoming_calls = |text: &str| {
|
||||
text.split("## 外部调用方\n")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("## 能力与内部调用\n")
|
||||
.next()
|
||||
.unwrap()
|
||||
.matches("| [hello.greeting.greet]")
|
||||
.count()
|
||||
};
|
||||
assert_eq!(incoming_calls(&first), 4);
|
||||
assert!(first.contains("shop.checkout.welcome"));
|
||||
assert!(first.contains("shop.account.welcome"));
|
||||
assert!(first.contains("../../shop/checkout/app.dever#L2"));
|
||||
let outgoing = fs::read_to_string(root.join("module/shop/checkout/link.md")).unwrap();
|
||||
assert!(outgoing.contains("跨领域 [hello.greeting](../../hello/greeting/link.md)"));
|
||||
let untouched = unrelated.join("link.md");
|
||||
let modified = fs::metadata(&untouched).unwrap().modified().unwrap();
|
||||
fs::write(
|
||||
root.join("module/shop/checkout/app.dever"),
|
||||
"welcome(name: Text) (message: Text) {\n message = hello.greeting.greet(name) + \"!\"\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let stale = inspect(&root, true);
|
||||
assert_eq!(stale.status.code(), Some(1));
|
||||
let report = String::from_utf8(stale.stdout).unwrap();
|
||||
assert!(report.contains("stale module/hello/greeting/link.md"));
|
||||
assert!(report.contains("stale module/shop/checkout/link.md"));
|
||||
assert!(!report.contains("stale module/empty/types/link.md"));
|
||||
success(inspect(&root, false));
|
||||
assert_eq!(incoming_calls(&fs::read_to_string(&incoming).unwrap()), 3);
|
||||
assert_eq!(
|
||||
fs::metadata(&untouched).unwrap().modified().unwrap(),
|
||||
modified
|
||||
);
|
||||
|
||||
fs::remove_file(root.join("module/shop/account/app.dever")).unwrap();
|
||||
fs::remove_file(root.join("module/shop/account/api.dever")).unwrap();
|
||||
let removed = root.join("module/shop/account/link.md");
|
||||
assert_eq!(inspect(&root, true).status.code(), Some(1));
|
||||
assert!(removed.exists());
|
||||
assert!(success(inspect(&root, false)).contains("removed module/shop/account/link.md"));
|
||||
assert!(!removed.exists());
|
||||
assert!(
|
||||
!fs::read_to_string(&incoming)
|
||||
.unwrap()
|
||||
.contains("shop.account.welcome")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_links_preserve_handwritten_files_and_reject_invalid_source_before_writing() {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("project");
|
||||
dever::compiler::project::create(&root).unwrap();
|
||||
let link = root.join("module/hello/greeting/link.md");
|
||||
fs::write(&link, "# 手工说明\n").unwrap();
|
||||
let refused = inspect(&root, false);
|
||||
assert!(!refused.status.success());
|
||||
assert!(String::from_utf8_lossy(&refused.stderr).contains("handwritten"));
|
||||
assert_eq!(fs::read_to_string(&link).unwrap(), "# 手工说明\n");
|
||||
fs::remove_file(&link).unwrap();
|
||||
success(inspect(&root, false));
|
||||
let first = fs::read_to_string(&link).unwrap();
|
||||
let source = root.join("module/hello/greeting/app.dever");
|
||||
let text = fs::read_to_string(&source).unwrap();
|
||||
fs::write(&source, text.replace("\"Hello, \" + name", "unknown(name)")).unwrap();
|
||||
assert!(!inspect(&root, false).status.success());
|
||||
assert_eq!(fs::read_to_string(&link).unwrap(), first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_links_mark_api_job_and_dispatch_without_inventing_execution_order() {
|
||||
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../test/rust/fixtures")
|
||||
.canonicalize()
|
||||
.unwrap();
|
||||
let job = fixtures.join("job");
|
||||
let pages = Project::load(&job)
|
||||
.unwrap()
|
||||
.inspect()
|
||||
.unwrap()
|
||||
.links(&job)
|
||||
.unwrap();
|
||||
let text = &pages[&job.join("module/demo/queue/link.md")];
|
||||
assert!(text.contains("Job [demo.queue.job.publish]"));
|
||||
assert!(text.contains("| 入队 |"));
|
||||
assert!(text.contains("按求值顺序"));
|
||||
assert!(text.contains("不是某次运行的日志"));
|
||||
assert!(text.contains("flowchart TD"));
|
||||
assert!(text.contains("由 Worker 后续调度,不等待执行"));
|
||||
let http = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../skills/assets/examples/http-rest")
|
||||
.canonicalize()
|
||||
.unwrap();
|
||||
let pages = Project::load(&http)
|
||||
.unwrap()
|
||||
.inspect()
|
||||
.unwrap()
|
||||
.links(&http)
|
||||
.unwrap();
|
||||
assert!(pages.values().any(|text| text.contains("- API [")));
|
||||
assert!(pages.values().all(|text| text.contains("自动 REST")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_links_reject_conflicting_options_and_non_project_sources() {
|
||||
let directory = Directory::new();
|
||||
let source = directory.0.join("main.dever");
|
||||
fs::write(&source, "main() (value: Int) { value = 1 }\n").unwrap();
|
||||
let invalid = inspect(&source, false);
|
||||
assert!(!invalid.status.success());
|
||||
assert!(String::from_utf8_lossy(&invalid.stderr).contains("project root"));
|
||||
for flags in [
|
||||
vec!["--check"],
|
||||
vec!["--links", "--json"],
|
||||
vec!["--links", "--symbol", "main.main"],
|
||||
vec!["--links", "--links"],
|
||||
] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.arg("inspect")
|
||||
.arg(&source)
|
||||
.args(flags)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user