1pub fn build_graph(
4 source: &dyn crate::source::Source,
5 config: &crate::config::Config,
6) -> anyhow::Result<crate::model::Graph> {
7 use crate::model::{
8 desc_edge_id, edge_id, is_valid_id, Broken, Edge, EdgeKind, Invalid, Node, NodeKey,
9 NodeKind, ReasonCode, TargetKind,
10 };
11 use std::collections::{BTreeMap, BTreeSet};
12
13 let mut graph = crate::model::Graph::default();
14 let files: Vec<String> = source
15 .list_files()?
16 .into_iter()
17 .filter(|path| !crate::source::is_under_dir_named(path, &config.exclude))
18 .collect();
19 let file_set: std::collections::HashSet<&str> = files.iter().map(|s| s.as_str()).collect();
20
21 struct Candidate {
22 path: String,
23 kind: NodeKind,
24 id: Option<String>,
25 body: String,
26 description: Option<String>,
27 }
28 let mut candidates: Vec<Candidate> = Vec::new();
29 let mut texts: BTreeMap<String, crate::index::ParsedText> = BTreeMap::new();
30 let index_roots: Vec<String> = config
31 .index
32 .iter()
33 .map(|r| crate::config::normalize_dir(r))
34 .collect();
35
36 for path in &files {
37 if !path.ends_with(".md") {
38 continue;
39 }
40 if !(is_under_any(path, &config.docs) || is_under_any(path, &config.requirements)) {
41 continue;
42 }
43 let bytes = source.read(path)?;
44 let text = String::from_utf8_lossy(&bytes).into_owned();
45 let parsed = crate::frontmatter::parse(&text);
46 if !matches!(path.rsplit('/').next(), Some("index.md" | "log.md")) {
48 match &parsed {
49 Ok(Some(fm)) => {
50 let kind = match fm.kind.as_deref() {
51 Some("BusinessNeed") => Some(NodeKind::Doc),
52 Some("Requirement") => Some(NodeKind::Req),
53 _ => None,
54 };
55 if let Some(kind) = kind {
56 candidates.push(Candidate {
57 path: path.clone(),
58 kind,
59 id: fm.id.clone(),
60 body: fm.body.clone(),
61 description: crate::index::normalize_description(fm),
62 });
63 }
64 }
65 Ok(None) => {}
66 Err(_e) => {
67 graph.invalid.push(Invalid {
68 target_kind: TargetKind::File,
69 target: path.clone(),
70 reason: ReasonCode::ParseError,
71 detail: format!("invalid front matter in {path}"),
72 });
73 }
74 }
75 }
76 if index_roots.iter().any(|r| crate::config::is_under(path, r)) {
78 texts.insert(
79 path.clone(),
80 crate::index::ParsedText {
81 text,
82 front_matter: parsed,
83 },
84 );
85 }
86 }
87
88 let mut by_kind_id: BTreeMap<(NodeKind, String), Vec<usize>> = BTreeMap::new();
89 for (idx, c) in candidates.iter().enumerate() {
90 let target_kind = if c.kind == NodeKind::Doc {
91 TargetKind::Doc
92 } else {
93 TargetKind::Req
94 };
95 let id = match &c.id {
96 Some(id) if !id.is_empty() => id.clone(),
97 _ => {
98 graph.invalid.push(Invalid {
99 target_kind,
100 target: c.path.clone(),
101 reason: ReasonCode::MissingId,
102 detail: format!("{} has no id", c.path),
103 });
104 continue;
105 }
106 };
107 if !is_valid_id(&id) {
108 graph.invalid.push(Invalid {
109 target_kind,
110 target: id.clone(),
111 reason: ReasonCode::InvalidId,
112 detail: format!("{} has invalid id {id}", c.path),
113 });
114 continue;
115 }
116 by_kind_id.entry((c.kind, id)).or_default().push(idx);
117 }
118
119 let mut path_to_req_id: BTreeMap<String, String> = BTreeMap::new();
120
121 for ((kind, id), idxs) in by_kind_id {
122 if idxs.len() > 1 {
123 let target_kind = if kind == NodeKind::Doc {
124 TargetKind::Doc
125 } else {
126 TargetKind::Req
127 };
128 for &idx in &idxs {
129 graph.invalid.push(Invalid {
130 target_kind,
131 target: id.clone(),
132 reason: ReasonCode::DuplicateId,
133 detail: format!("duplicate id {id} at {}", candidates[idx].path),
134 });
135 }
136 continue;
137 }
138 let c = &candidates[idxs[0]];
139 let key = NodeKey {
140 kind,
141 id: id.clone(),
142 };
143 let hash = crate::hash::content_hash(&c.body);
144 graph.nodes.insert(
145 key.clone(),
146 Node {
147 key,
148 path: c.path.clone(),
149 content: c.body.clone(),
150 hash,
151 description: c.description.clone(),
152 },
153 );
154 if kind == NodeKind::Req {
155 path_to_req_id.insert(c.path.clone(), id.clone());
156 }
157 }
158
159 let desc_edges: Vec<Edge> = graph
161 .nodes
162 .values()
163 .filter(|n| matches!(n.key.kind, NodeKind::Doc | NodeKind::Req))
164 .filter(|n| {
165 index_roots
166 .iter()
167 .any(|r| crate::config::is_under(&n.path, r))
168 })
169 .map(|n| Edge {
170 id: desc_edge_id(&n.key),
171 kind: EdgeKind::Desc,
172 from: n.key.clone(),
173 to: n.key.clone(),
174 })
175 .collect();
176 for e in desc_edges {
177 graph.edges.insert(e.id.clone(), e);
178 }
179 let doc_entries: Vec<(NodeKey, String, String)> = graph
180 .nodes
181 .values()
182 .filter(|n| n.key.kind == NodeKind::Doc)
183 .map(|n| (n.key.clone(), n.path.clone(), n.content.clone()))
184 .collect();
185 for (doc_key, doc_path, body) in doc_entries {
186 let links = crate::markdown::extract_links(&body);
187 for link in links {
188 let Some(resolved) = crate::markdown::resolve_link(&doc_path, &link) else {
189 continue;
190 };
191 if !is_under_any(&resolved, &config.requirements) {
192 continue;
193 }
194 if let Some(req_id) = path_to_req_id.get(&resolved) {
195 let id = edge_id(EdgeKind::DocReq, &doc_key.id, req_id);
196 let req_key = NodeKey {
197 kind: NodeKind::Req,
198 id: req_id.clone(),
199 };
200 graph.edges.insert(
201 id.clone(),
202 Edge {
203 id,
204 kind: EdgeKind::DocReq,
205 from: doc_key.clone(),
206 to: req_key,
207 },
208 );
209 } else if file_set.contains(resolved.as_str()) {
210 graph.broken.push(Broken {
211 from: doc_key.clone(),
212 target: resolved.clone(),
213 reason: ReasonCode::NotReqFile,
214 detail: format!("{resolved} is not a requirement file"),
215 });
216 } else {
217 graph.broken.push(Broken {
218 from: doc_key.clone(),
219 target: resolved.clone(),
220 reason: ReasonCode::MissingReq,
221 detail: format!("{resolved} does not exist"),
222 });
223 }
224 }
225 }
226
227 let mut extracted_tests: Vec<(String, crate::extract::ExtractedTest)> = Vec::new();
228 for path in &files {
229 if !is_under_any(path, &config.tests) {
230 continue;
231 }
232 let Some(language) = test_language(path) else {
233 continue;
234 };
235 let bytes = source.read(path)?;
236 let text = String::from_utf8_lossy(&bytes).into_owned();
237 let result = match language {
238 TestLanguage::Go => crate::extract::extract_go_tests(path, &text),
239 TestLanguage::Rust => {
240 crate::extract::extract_rust_tests(path, &text, &config.rust.test_attributes)
241 }
242 TestLanguage::CSharp => crate::extract::extract_csharp_tests(path, &text),
243 TestLanguage::Java => crate::extract::extract_java_tests(path, &text),
244 TestLanguage::Kotlin => crate::extract::extract_kotlin_tests(path, &text),
245 TestLanguage::TypeScript => crate::extract::extract_typescript_tests(path, &text),
246 };
247 match result {
248 Ok(tests) => {
249 extracted_tests.extend(
250 tests
251 .into_iter()
252 .filter(|t| !t.markers.is_empty())
253 .map(|t| (path.clone(), t)),
254 );
255 }
256 Err(_e) => {
257 graph.invalid.push(Invalid {
258 target_kind: TargetKind::File,
259 target: path.clone(),
260 reason: ReasonCode::ParseError,
261 detail: format!("failed to parse {path}"),
262 });
263 }
264 }
265 }
266
267 let mut by_test_id: BTreeMap<String, Vec<(String, crate::extract::ExtractedTest)>> =
271 BTreeMap::new();
272 for (path, t) in extracted_tests {
273 if t.markers.len() > 1 {
274 graph.invalid.push(Invalid {
275 target_kind: TargetKind::Test,
276 target: t.id.clone(),
277 reason: ReasonCode::MultipleMarkers,
278 detail: format!("{} has multiple markers: {:?}", t.id, t.markers),
279 });
280 continue;
281 }
282 by_test_id.entry(t.id.clone()).or_default().push((path, t));
283 }
284
285 for (test_id, occurrences) in by_test_id {
286 let req_ids: std::collections::BTreeSet<&String> =
287 occurrences.iter().map(|(_, t)| &t.markers[0]).collect();
288 if req_ids.len() > 1 {
289 let paths: BTreeSet<&str> = occurrences.iter().map(|(p, _)| p.as_str()).collect();
290 let reqs: Vec<&str> = req_ids.iter().map(|r| r.as_str()).collect();
291 graph.invalid.push(Invalid {
292 target_kind: TargetKind::Test,
293 target: test_id.clone(),
294 reason: ReasonCode::DuplicateId,
295 detail: format!(
296 "duplicate test id {test_id} points at different requirements ({}) at {}",
297 reqs.join(", "),
298 paths.into_iter().collect::<Vec<_>>().join(", ")
299 ),
300 });
301 continue;
302 }
303 let req_id = occurrences[0].1.markers[0].clone();
304 let path = occurrences[0].0.clone();
305 let content = occurrences
306 .iter()
307 .map(|(_, t)| t.content.as_str())
308 .collect::<Vec<_>>()
309 .join("\n");
310 let test_key = NodeKey {
311 kind: NodeKind::Test,
312 id: test_id.clone(),
313 };
314 let hash = crate::hash::content_hash(&content);
315 graph.nodes.insert(
316 test_key.clone(),
317 Node {
318 key: test_key.clone(),
319 path,
320 content,
321 hash,
322 description: None,
323 },
324 );
325 let req_key = NodeKey {
326 kind: NodeKind::Req,
327 id: req_id.clone(),
328 };
329 if graph.nodes.contains_key(&req_key) {
330 let id = edge_id(EdgeKind::TestReq, &test_id, &req_id);
331 graph.edges.insert(
332 id.clone(),
333 Edge {
334 id,
335 kind: EdgeKind::TestReq,
336 from: test_key,
337 to: req_key,
338 },
339 );
340 } else {
341 graph.broken.push(Broken {
342 from: test_key,
343 target: req_id.clone(),
344 reason: ReasonCode::MissingReq,
345 detail: format!("{req_id} does not exist"),
346 });
347 }
348 }
349
350 if !config.index.is_empty() {
352 graph.index_issues = crate::index::collect_issues(config, &files, &texts)
353 .into_iter()
354 .collect();
355 }
356
357 graph.invalid.sort();
358 graph.broken.sort();
359 Ok(graph)
360}
361
362fn is_under_any(path: &str, dirs: &[String]) -> bool {
363 dirs.iter()
364 .any(|d| crate::config::is_under(path, &crate::config::normalize_dir(d)))
365}
366
367#[derive(Debug, Clone, Copy)]
368enum TestLanguage {
369 Go,
370 Rust,
371 CSharp,
372 Java,
373 Kotlin,
374 TypeScript,
375}
376
377fn test_language(path: &str) -> Option<TestLanguage> {
380 if path.ends_with("_test.go") {
381 Some(TestLanguage::Go)
382 } else if path.ends_with(".rs") {
383 Some(TestLanguage::Rust)
384 } else if path.ends_with(".cs") {
385 Some(TestLanguage::CSharp)
386 } else if path.ends_with(".java") {
387 Some(TestLanguage::Java)
388 } else if path.ends_with(".kt") {
389 Some(TestLanguage::Kotlin)
390 } else if crate::extract::is_typescript_test_file(path) {
391 Some(TestLanguage::TypeScript)
392 } else {
393 None
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use crate::config::{Config, JudgeConfig};
401 use crate::model::{NodeKind, ReasonCode, TargetKind};
402 use crate::source::Source;
403 use std::collections::BTreeMap;
404
405 struct FakeSource {
406 files: BTreeMap<String, Vec<u8>>,
407 }
408
409 impl FakeSource {
410 fn new(files: &[(&str, &str)]) -> FakeSource {
411 FakeSource {
412 files: files
413 .iter()
414 .map(|(p, c)| (p.to_string(), c.as_bytes().to_vec()))
415 .collect(),
416 }
417 }
418 }
419
420 impl Source for FakeSource {
421 fn list_files(&self) -> anyhow::Result<Vec<String>> {
422 Ok(self.files.keys().cloned().collect())
423 }
424
425 fn read(&self, path: &str) -> anyhow::Result<Vec<u8>> {
426 self.files
427 .get(path)
428 .cloned()
429 .ok_or_else(|| anyhow::anyhow!("not found: {path}"))
430 }
431 }
432
433 fn config() -> Config {
434 Config {
435 docs: vec!["docs".to_string()],
436 requirements: vec!["req".to_string()],
437 tests: vec![".".to_string()],
438 diff_base: "main".to_string(),
439 judge: JudgeConfig {
440 threshold: 0.9,
441 max_body_bytes: 200_000,
442 },
443 rust: crate::config::RustConfig::default(),
444 exclude: vec![],
445 index: vec![],
446 site: None,
447 }
448 }
449
450 #[test]
452 fn typeフィールドのないmdファイルはノードとして扱われない() {
453 let source = FakeSource::new(&[
454 ("docs/a.md", "# no frontmatter\nplain text\n"),
455 (
456 "req/fr1.md",
457 "---\ntype: Requirement\nid: FR-001\n---\n本文\n",
458 ),
459 ]);
460 let graph = build_graph(&source, &config()).unwrap();
461 assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Doc));
462 assert!(
463 graph.invalid.is_empty(),
464 "should be silently excluded, not invalid: {:?}",
465 graph.invalid
466 );
467 }
468
469 #[test]
471 fn typeの値がbusiness_need_requirement以外なら対象外になる() {
472 let source = FakeSource::new(&[
473 ("docs/b.md", "---\ntype: Note\nid: DOC-B\n---\n本文\n"),
474 (
475 "req/fr1.md",
476 "---\ntype: Requirement\nid: FR-001\n---\n本文\n",
477 ),
478 ]);
479 let graph = build_graph(&source, &config()).unwrap();
480 assert!(!graph.nodes.values().any(|n| n.key.id == "DOC-B"));
481 }
482
483 #[test]
485 fn type_specificationの文書はdocノードにならない() {
486 let source = FakeSource::new(&[
487 (
488 "docs/old.md",
489 "---\ntype: Specification\nid: DOC-OLD\n---\n本文\n",
490 ),
491 (
492 "req/fr1.md",
493 "---\ntype: Requirement\nid: FR-001\n---\n本文\n",
494 ),
495 ]);
496 let graph = build_graph(&source, &config()).unwrap();
497 assert!(!graph.nodes.values().any(|n| n.key.id == "DOC-OLD"));
498 assert!(
499 graph.invalid.is_empty(),
500 "should be silently excluded, not invalid: {:?}",
501 graph.invalid
502 );
503 }
504
505 #[test]
507 fn 不正な文字を含むidはinvalid_idとして報告される() {
508 let source = FakeSource::new(&[(
509 "req/bad.md",
510 "---\ntype: Requirement\nid: FR:001\n---\n本文\n",
511 )]);
512 let graph = build_graph(&source, &config()).unwrap();
513 assert!(graph
514 .invalid
515 .iter()
516 .any(|i| i.reason == ReasonCode::InvalidId && i.target_kind == TargetKind::Req));
517 }
518
519 #[test]
521 fn idが空のファイルはmissing_idとして報告される() {
522 let source = FakeSource::new(&[(
523 "req/empty-id.md",
524 "---\ntype: Requirement\nid: \n---\n本文\n",
525 )]);
526 let graph = build_graph(&source, &config()).unwrap();
527 assert!(graph
528 .invalid
529 .iter()
530 .any(|i| i.reason == ReasonCode::MissingId));
531 }
532
533 #[test]
535 fn 同じidを持つファイルが複数あるとduplicate_idとして報告される() {
536 let source = FakeSource::new(&[
537 (
538 "req/fr1.md",
539 "---\ntype: Requirement\nid: FR-001\n---\n本文A\n",
540 ),
541 (
542 "req/fr1-dup.md",
543 "---\ntype: Requirement\nid: FR-001\n---\n本文B\n",
544 ),
545 ]);
546 let graph = build_graph(&source, &config()).unwrap();
547 assert!(graph
548 .invalid
549 .iter()
550 .any(|i| i.reason == ReasonCode::DuplicateId && i.target == "FR-001"));
551 }
552
553 #[test]
555 fn requirements領域外を指すリンクはエッジにもbrokenにもならない() {
556 let source = FakeSource::new(&[
557 (
558 "docs/a.md",
559 "---\ntype: BusinessNeed\nid: DOC-A\n---\n[外部req](../outside/x.md)\n",
560 ),
561 (
562 "outside/x.md",
563 "---\ntype: Requirement\nid: FR-999\n---\n本文\n",
564 ),
565 ]);
566 let graph = build_graph(&source, &config()).unwrap();
567 assert!(graph
568 .edges
569 .values()
570 .all(|e| e.kind != crate::model::EdgeKind::DocReq));
571 assert!(
572 graph.broken.is_empty(),
573 "should not be reported as broken: {:?}",
574 graph.broken
575 );
576 }
577
578 #[test]
580 fn 存在しないreqファイルを指すリンクはbrokenになる() {
581 let source = FakeSource::new(&[(
582 "docs/a.md",
583 "---\ntype: BusinessNeed\nid: DOC-A\n---\n[リンク](../req/missing.md)\n",
584 )]);
585 let graph = build_graph(&source, &config()).unwrap();
586 assert!(graph
587 .broken
588 .iter()
589 .any(|b| b.from.kind == NodeKind::Doc && b.reason == ReasonCode::MissingReq));
590 }
591
592 #[test]
594 fn requirement型でないファイルを指すリンクはnot_req_fileとしてbrokenになる() {
595 let source = FakeSource::new(&[
596 (
597 "docs/a.md",
598 "---\ntype: BusinessNeed\nid: DOC-A\n---\n[リンク](../req/not-a-req.md)\n",
599 ),
600 (
601 "req/not-a-req.md",
602 "---\ntype: BusinessNeed\nid: DOC-FAKE\n---\n本文\n",
603 ),
604 ]);
605 let graph = build_graph(&source, &config()).unwrap();
606 assert!(graph
607 .broken
608 .iter()
609 .any(|b| b.from.kind == NodeKind::Doc && b.reason == ReasonCode::NotReqFile));
610 }
611
612 #[test]
614 fn 存在しないreq_idを指すtestマーカーはbrokenになる() {
615 let source = FakeSource::new(&[(
616 "src/foo.rs",
617 "// [FR-999]\n#[test]\nfn does_something() {}\n",
618 )]);
619 let graph = build_graph(&source, &config()).unwrap();
620 assert!(graph
621 .broken
622 .iter()
623 .any(|b| b.from.kind == NodeKind::Test && b.reason == ReasonCode::MissingReq));
624 }
625
626 #[test]
628 fn go_1つのテストに2件以上のマーカーがあるとinvalidになる() {
629 let source = FakeSource::new(&[(
630 "main_test.go",
631 "package main\n\n// [FR-001]\n// [FR-002]\nfunc TestBoth(t *testing.T) {}\n",
632 )]);
633 let graph = build_graph(&source, &config()).unwrap();
634 assert!(graph
635 .invalid
636 .iter()
637 .any(|i| i.reason == ReasonCode::MultipleMarkers));
638 }
639
640 #[test]
642 fn 構文エラーのrustテストファイルはparse_errorとしてinvalidになる() {
643 let source = FakeSource::new(&[("broken.rs", "fn totally #%^& invalid syntax {\n")]);
644 let graph = build_graph(&source, &config()).unwrap();
645 assert!(graph
646 .invalid
647 .iter()
648 .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("broken.rs")));
649 }
650
651 #[test]
653 fn rust_1つのテストに2件以上のマーカーがあるとinvalidになる() {
654 let source = FakeSource::new(&[(
655 "src/foo.rs",
656 "// [FR-001]\n// [FR-002]\n#[test]\nfn does_both() {}\n",
657 )]);
658 let graph = build_graph(&source, &config()).unwrap();
659 assert!(graph
660 .invalid
661 .iter()
662 .any(|i| i.reason == ReasonCode::MultipleMarkers));
663 }
664
665 #[test]
667 fn 構文エラーのgoテストファイルはparse_errorとしてinvalidになる() {
668 let source = FakeSource::new(&[("broken_test.go", "package main\n\nfunc TestBroken( {\n")]);
669 let graph = build_graph(&source, &config()).unwrap();
670 assert!(graph
671 .invalid
672 .iter()
673 .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("broken_test.go")));
674 }
675
676 #[test]
678 fn configのディレクトリ表記がドットスラッシュや末尾スラッシュ付きでも拾われる() {
679 let source = FakeSource::new(&[
680 (
681 "docs/spec.md",
682 "---\ntype: BusinessNeed\nid: DOC-A\n---\n[リンク](../req/fr1.md)\n",
683 ),
684 (
685 "req/fr1.md",
686 "---\ntype: Requirement\nid: FR-001\n---\n本文\n",
687 ),
688 ]);
689 let config = Config {
690 docs: vec!["./docs".to_string()],
691 requirements: vec!["req/".to_string()],
692 tests: vec![".".to_string()],
693 diff_base: "main".to_string(),
694 judge: JudgeConfig {
695 threshold: 0.9,
696 max_body_bytes: 200_000,
697 },
698 rust: crate::config::RustConfig::default(),
699 exclude: vec![],
700 index: vec![],
701 site: None,
702 };
703 let graph = build_graph(&source, &config).unwrap();
704 assert!(graph
705 .nodes
706 .keys()
707 .any(|k| k.kind == NodeKind::Doc && k.id == "DOC-A"));
708 assert!(graph
709 .nodes
710 .keys()
711 .any(|k| k.kind == NodeKind::Req && k.id == "FR-001"));
712 assert!(graph.edges.contains_key("doc-req:DOC-A:FR-001"));
713 }
714
715 const REQ1: (&str, &str) = (
716 "req/fr1.md",
717 "---\ntype: Requirement\nid: FR-001\n---\n本文\n",
718 );
719
720 fn has_test_node(graph: &crate::model::Graph, id: &str) -> bool {
721 graph
722 .nodes
723 .keys()
724 .any(|k| k.kind == NodeKind::Test && k.id == id)
725 }
726
727 #[test]
729 fn csharpファイルからtestノードとエッジが作られる() {
730 let source = FakeSource::new(&[
731 REQ1,
732 (
733 "tests/AuthTests.cs",
734 "public class AuthTests\n{\n // [FR-001]\n [Fact]\n public void T() { }\n}\n",
735 ),
736 ]);
737 let graph = build_graph(&source, &config()).unwrap();
738 assert!(has_test_node(&graph, "tests/AuthTests:AuthTests:T"));
739 assert!(graph
740 .edges
741 .contains_key("test-req:tests/AuthTests:AuthTests:T:FR-001"));
742 }
743
744 #[test]
746 fn configのrust_test_attributesが抽出に反映される() {
747 let src = "// [FR-001]\n#[rstest]\nfn t() {}\n";
748 let source = FakeSource::new(&[REQ1, ("src/a.rs", src)]);
749
750 let graph = build_graph(&source, &config()).unwrap();
751 assert!(!has_test_node(&graph, "src/a::t"));
752
753 let mut cfg = config();
754 cfg.rust.test_attributes = vec!["rstest".to_string()];
755 let graph = build_graph(&source, &cfg).unwrap();
756 assert!(has_test_node(&graph, "src/a::t"));
757 assert!(graph.edges.contains_key("test-req:src/a::t:FR-001"));
758 }
759
760 #[test]
762 fn typescriptのtest_tsとspec_tsxからtestノードが作られる() {
763 let source = FakeSource::new(&[
764 REQ1,
765 ("src/a.test.ts", "// [FR-001]\nit(\"works\", () => {});\n"),
766 (
767 "src/b.spec.tsx",
768 "// [FR-001]\nit(\"renders\", () => { const x = <div />; });\n",
769 ),
770 ]);
771 let graph = build_graph(&source, &config()).unwrap();
772 assert!(has_test_node(&graph, "src/a.test::works"));
773 assert!(has_test_node(&graph, "src/b.spec::renders"));
774 }
775
776 #[test]
778 fn typescriptのtestやspecを含まない通常のtsファイルは走査されない() {
779 let source = FakeSource::new(&[
780 REQ1,
781 ("src/a.ts", "// [FR-001]\nit(\"works\", () => {});\n"),
782 ("src/b.tsx", "// [FR-001]\nit(\"works\", () => {});\n"),
783 ]);
784 let graph = build_graph(&source, &config()).unwrap();
785 assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Test));
786 assert!(graph.invalid.is_empty());
787 }
788
789 #[test]
791 fn javascriptのtest_jsとspec_mjsからtestノードが作られる() {
792 let source = FakeSource::new(&[
793 REQ1,
794 ("src/a.test.js", "// [FR-001]\nit(\"works\", () => {});\n"),
795 ("src/b.spec.mjs", "// [FR-001]\nit(\"loads\", () => {});\n"),
796 ]);
797 let graph = build_graph(&source, &config()).unwrap();
798 assert!(has_test_node(&graph, "src/a.test::works"));
799 assert!(has_test_node(&graph, "src/b.spec::loads"));
800 }
801
802 #[test]
804 fn testやspecを含まない通常のjsファイルは走査されない() {
805 let source = FakeSource::new(&[
806 REQ1,
807 ("src/a.js", "// [FR-001]\nit(\"works\", () => {});\n"),
808 ]);
809 let graph = build_graph(&source, &config()).unwrap();
810 assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Test));
811 assert!(graph.invalid.is_empty());
812 }
813
814 #[test]
816 fn 構文エラーのjsテストファイルはparse_errorとしてinvalidになる() {
817 let source = FakeSource::new(&[("src/broken.test.js", "it(\"t\", () => {\n")]);
818 let graph = build_graph(&source, &config()).unwrap();
819 assert!(graph
820 .invalid
821 .iter()
822 .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("broken.test.js")));
823 }
824
825 const REQ2: (&str, &str) = (
826 "req/fr2.md",
827 "---\ntype: Requirement\nid: FR-002\n---\n本文2\n",
828 );
829
830 fn test_node<'a>(graph: &'a crate::model::Graph, id: &str) -> &'a crate::model::Node {
831 graph
832 .nodes
833 .values()
834 .find(|n| n.key.kind == NodeKind::Test && n.key.id == id)
835 .unwrap_or_else(|| panic!("test node {id} not found"))
836 }
837
838 fn cs_overloads(first: &str, second: &str) -> String {
839 format!(
840 "public class AuthTests\n{{\n // [FR-001]\n [Fact]\n public void T() {{ {first} }}\n\n // [FR-001]\n [Fact]\n public void T(int x) {{ {second} }}\n}}\n"
841 )
842 }
843
844 #[test]
846 fn csharpのオーバーロードは1つのtestにまとめられcontentが出現順に連結される() {
847 let source = FakeSource::new(&[
848 REQ1,
849 ("tests/AuthTests.cs", &cs_overloads("one();", "two();")),
850 ]);
851 let graph = build_graph(&source, &config()).unwrap();
852 assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
853 let tests: Vec<_> = graph
854 .nodes
855 .keys()
856 .filter(|k| k.kind == NodeKind::Test)
857 .collect();
858 assert_eq!(tests.len(), 1, "{tests:?}");
859 let edges: Vec<_> = graph
860 .edges
861 .values()
862 .filter(|e| e.kind == crate::model::EdgeKind::TestReq)
863 .collect();
864 assert_eq!(edges.len(), 1, "{edges:?}");
865 let node = test_node(&graph, "tests/AuthTests:AuthTests:T");
866 let a = node.content.find("one();").expect("first occurrence");
867 let b = node.content.find("two();").expect("second occurrence");
868 assert!(a < b, "{:?}", node.content);
869 let between = &node.content[a..b];
870 assert!(between.contains('\n'), "{:?}", node.content);
871 assert!(node.content.contains("public void T()"));
872 assert!(node.content.contains("public void T(int x)"));
873 }
874
875 #[test]
877 fn goの別ファイルにある同名testはファイルパス順に連結される() {
878 let source = FakeSource::new(&[
879 REQ1,
880 (
881 "pkg/b_test.go",
882 "package pkg\n\n// [FR-001]\nfunc TestX(t *testing.T) { second_body() }\n",
883 ),
884 (
885 "pkg/a_test.go",
886 "package pkg\n\n// [FR-001]\nfunc TestX(t *testing.T) { first_body() }\n",
887 ),
888 ]);
889 let graph = build_graph(&source, &config()).unwrap();
890 assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
891 let node = test_node(&graph, "pkg:TestX");
892 let a = node.content.find("first_body").expect("a_test.go");
893 let b = node.content.find("second_body").expect("b_test.go");
894 assert!(a < b, "{:?}", node.content);
895 }
896
897 #[test]
899 fn rustのcfg分岐による同名testは1つのtestにまとめられる() {
900 let source = FakeSource::new(&[
901 REQ1,
902 (
903 "src/lib.rs",
904 "#[cfg(unix)]\n// [FR-001]\n#[test]\nfn same() { unix_body(); }\n\n#[cfg(windows)]\n// [FR-001]\n#[test]\nfn same() { windows_body(); }\n",
905 ),
906 ]);
907 let graph = build_graph(&source, &config()).unwrap();
908 assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
909 let node = test_node(&graph, "src/lib::same");
910 let a = node.content.find("unix_body").expect("unix");
911 let b = node.content.find("windows_body").expect("windows");
912 assert!(a < b, "{:?}", node.content);
913 }
914
915 #[test]
917 fn typescriptの同名testは1つのtestにまとめられる() {
918 let source = FakeSource::new(&[
919 REQ1,
920 (
921 "src/a.test.ts",
922 "// [FR-001]\nit(\"same\", () => { first(); });\n\n// [FR-001]\nit(`same`, () => { second(); });\n",
923 ),
924 ]);
925 let graph = build_graph(&source, &config()).unwrap();
926 assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
927 let node = test_node(&graph, "src/a.test::same");
928 assert!(node.content.contains("first();"));
929 assert!(node.content.contains("second();"));
930 }
931
932 #[test]
934 fn まとめたtestは出現の本体が変わるとhashが変わる() {
935 let hash_of = |first: &str, second: &str| {
936 let source =
937 FakeSource::new(&[REQ1, ("tests/AuthTests.cs", &cs_overloads(first, second))]);
938 let graph = build_graph(&source, &config()).unwrap();
939 test_node(&graph, "tests/AuthTests:AuthTests:T")
940 .hash
941 .clone()
942 };
943 let base = hash_of("one();", "two();");
944 assert_ne!(base, hash_of("one();", "changed();"));
945 assert_ne!(base, hash_of("changed();", "two();"));
946 }
947
948 #[test]
950 fn まとめたtestは出現の順序を入れ替えるとhashが変わる() {
951 let hash_of = |first: &str, second: &str| {
952 let src = format!(
953 "public class AuthTests\n{{\n // [FR-001]\n [Fact]\n public void T({first}) {{ }}\n\n // [FR-001]\n [Fact]\n public void T({second}) {{ }}\n}}\n"
954 );
955 let source = FakeSource::new(&[REQ1, ("tests/AuthTests.cs", &src)]);
956 let graph = build_graph(&source, &config()).unwrap();
957 test_node(&graph, "tests/AuthTests:AuthTests:T")
958 .hash
959 .clone()
960 };
961 assert_ne!(hash_of("int x", "string s"), hash_of("string s", "int x"));
962 }
963
964 #[test]
966 fn 異なるreqを指す重複はduplicate_idとなりノードもエッジも作られない() {
967 let source = FakeSource::new(&[
968 REQ1,
969 REQ2,
970 (
971 "pkg/a_test.go",
972 "package pkg\n\n// [FR-001]\nfunc TestX(t *testing.T) {}\n",
973 ),
974 (
975 "pkg/b_test.go",
976 "package pkg\n\n// [FR-002]\nfunc TestX(t *testing.T) {}\n",
977 ),
978 ]);
979 let graph = build_graph(&source, &config()).unwrap();
980 let dups: Vec<_> = graph
981 .invalid
982 .iter()
983 .filter(|i| i.reason == ReasonCode::DuplicateId)
984 .collect();
985 assert_eq!(dups.len(), 1, "{:?}", graph.invalid);
986 assert_eq!(dups[0].target_kind, TargetKind::Test);
987 assert_eq!(dups[0].target, "pkg:TestX");
988 assert!(dups[0].detail.contains("pkg/a_test.go"));
989 assert!(dups[0].detail.contains("pkg/b_test.go"));
990 assert!(dups[0].detail.contains("FR-001"));
991 assert!(dups[0].detail.contains("FR-002"));
992 assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Test));
993 assert!(graph
994 .edges
995 .values()
996 .all(|e| e.kind != crate::model::EdgeKind::TestReq));
997 let findings = crate::check::check(&graph, &crate::lock::Lock::default());
998 for req in ["FR-001", "FR-002"] {
999 assert!(
1000 findings
1001 .iter()
1002 .any(|f| f.kind == crate::check::FindingKind::NoTest && f.target == req),
1003 "{req}: {findings:?}"
1004 );
1005 }
1006 }
1007
1008 #[test]
1010 fn 同一ファイル内で異なるreqを指す重複は識別子ごとに1件でファイル名は1回だけ出る() {
1011 let source = FakeSource::new(&[
1012 REQ1,
1013 REQ2,
1014 (
1015 "tests/AuthTests.cs",
1016 "public class AuthTests\n{\n // [FR-001]\n [Fact]\n public void T() { }\n\n // [FR-002]\n [Fact]\n public void T(int x) { }\n}\n",
1017 ),
1018 ]);
1019 let graph = build_graph(&source, &config()).unwrap();
1020 let dups: Vec<_> = graph
1021 .invalid
1022 .iter()
1023 .filter(|i| i.reason == ReasonCode::DuplicateId)
1024 .collect();
1025 assert_eq!(dups.len(), 1, "{:?}", graph.invalid);
1026 assert_eq!(dups[0].detail.matches("tests/AuthTests.cs").count(), 1);
1027 assert!(dups[0].detail.contains("FR-001"));
1028 assert!(dups[0].detail.contains("FR-002"));
1029 assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Test));
1030 }
1031
1032 #[test]
1034 fn 重複の1つが複数マーカーならその出現はmultiple_markersで残りだけがまとめられる() {
1035 let source = FakeSource::new(&[
1036 REQ1,
1037 REQ2,
1038 (
1039 "tests/AuthTests.cs",
1040 "public class AuthTests\n{\n // [FR-001]\n // [FR-002]\n [Fact]\n public void T() { multi(); }\n\n // [FR-001]\n [Fact]\n public void T(int x) { single_a(); }\n\n // [FR-001]\n [Fact]\n public void T(string s) { single_b(); }\n}\n",
1041 ),
1042 ]);
1043 let graph = build_graph(&source, &config()).unwrap();
1044 assert!(
1045 graph
1046 .invalid
1047 .iter()
1048 .any(|i| i.reason == ReasonCode::MultipleMarkers),
1049 "{:?}",
1050 graph.invalid
1051 );
1052 assert!(!graph
1053 .invalid
1054 .iter()
1055 .any(|i| i.reason == ReasonCode::DuplicateId));
1056 let node = test_node(&graph, "tests/AuthTests:AuthTests:T");
1057 assert!(node.content.contains("single_a();"));
1058 assert!(node.content.contains("single_b();"));
1059 assert!(!node.content.contains("multi();"));
1060 }
1061
1062 #[test]
1064 fn 重複の1つが複数マーカーで残りが1件なら通常のtestとして扱われる() {
1065 let source = FakeSource::new(&[
1066 REQ1,
1067 REQ2,
1068 (
1069 "tests/AuthTests.cs",
1070 "public class AuthTests\n{\n // [FR-001]\n // [FR-002]\n [Fact]\n public void T() { multi(); }\n\n // [FR-001]\n [Fact]\n public void T(int x) { single_a(); }\n}\n",
1071 ),
1072 ]);
1073 let graph = build_graph(&source, &config()).unwrap();
1074 assert!(graph
1075 .invalid
1076 .iter()
1077 .any(|i| i.reason == ReasonCode::MultipleMarkers));
1078 assert!(!graph
1079 .invalid
1080 .iter()
1081 .any(|i| i.reason == ReasonCode::DuplicateId));
1082 let node = test_node(&graph, "tests/AuthTests:AuthTests:T");
1083 assert!(node.content.contains("single_a();"));
1084 assert!(!node.content.contains("multi();"));
1085 }
1086
1087 #[test]
1089 fn マーカーのない同名出現はまとめられない() {
1090 let source = FakeSource::new(&[
1091 REQ1,
1092 (
1093 "tests/AuthTests.cs",
1094 "public class AuthTests\n{\n // [FR-001]\n [Fact]\n public void T() { marked_body(); }\n\n [Fact]\n public void T(int x) { unmarked_body(); }\n}\n",
1095 ),
1096 ]);
1097 let graph = build_graph(&source, &config()).unwrap();
1098 assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
1099 let node = test_node(&graph, "tests/AuthTests:AuthTests:T");
1100 assert!(node.content.contains("marked_body();"));
1101 assert!(!node.content.contains("unmarked_body();"));
1102 }
1103
1104 #[test]
1106 fn 構文エラーのcsharpテストファイルはparse_errorとしてinvalidになる() {
1107 let source = FakeSource::new(&[(
1108 "tests/Broken.cs",
1109 "public class A {\n [Fact]\n public void T( {\n}\n",
1110 )]);
1111 let graph = build_graph(&source, &config()).unwrap();
1112 assert!(graph
1113 .invalid
1114 .iter()
1115 .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("Broken.cs")));
1116 }
1117
1118 #[test]
1120 fn javaファイルからtestノードとエッジが作られる() {
1121 let source = FakeSource::new(&[
1122 REQ1,
1123 (
1124 "tests/AuthTests.java",
1125 "public class AuthTests {\n // [FR-001]\n @Test\n void t() { }\n}\n",
1126 ),
1127 ]);
1128 let graph = build_graph(&source, &config()).unwrap();
1129 assert!(has_test_node(&graph, "tests/AuthTests:AuthTests:t"));
1130 assert!(graph
1131 .edges
1132 .contains_key("test-req:tests/AuthTests:AuthTests:t:FR-001"));
1133 }
1134
1135 #[test]
1137 fn kotlinファイルからtestノードとエッジが作られる() {
1138 let source = FakeSource::new(&[
1139 REQ1,
1140 (
1141 "tests/AuthTest.kt",
1142 "class AuthTest {\n // [FR-001]\n @Test\n fun `一覧 取得`() { }\n}\n",
1143 ),
1144 ]);
1145 let graph = build_graph(&source, &config()).unwrap();
1146 assert!(has_test_node(&graph, "tests/AuthTest:AuthTest:一覧 取得"));
1147 assert!(graph
1148 .edges
1149 .contains_key("test-req:tests/AuthTest:AuthTest:一覧 取得:FR-001"));
1150 }
1151
1152 #[test]
1154 fn kotlinスクリプトのktsファイルは対象外になる() {
1155 let source = FakeSource::new(&[
1156 REQ1,
1157 (
1158 "tests/build.kts",
1159 "class AuthTest {\n // [FR-001]\n @Test\n fun t() { }\n}\n",
1160 ),
1161 ]);
1162 let graph = build_graph(&source, &config()).unwrap();
1163 assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Test));
1164 assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
1165 }
1166
1167 #[test]
1169 fn javaのオーバーロードは1つのtestにまとめられる() {
1170 let src = "public class AuthTests {\n // [FR-001]\n @Test\n void t() { one(); }\n\n // [FR-001]\n @Test\n void t(int x) { two(); }\n}\n";
1171 let source = FakeSource::new(&[REQ1, ("tests/AuthTests.java", src)]);
1172 let graph = build_graph(&source, &config()).unwrap();
1173 assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
1174 let tests: Vec<_> = graph
1175 .nodes
1176 .keys()
1177 .filter(|k| k.kind == NodeKind::Test)
1178 .collect();
1179 assert_eq!(tests.len(), 1, "{tests:?}");
1180 let edges: Vec<_> = graph
1181 .edges
1182 .values()
1183 .filter(|e| e.kind == crate::model::EdgeKind::TestReq)
1184 .collect();
1185 assert_eq!(edges.len(), 1, "{edges:?}");
1186 let node = test_node(&graph, "tests/AuthTests:AuthTests:t");
1187 assert!(node.content.contains("one();"));
1188 assert!(node.content.contains("two();"));
1189 }
1190
1191 #[test]
1193 fn 構文エラーのkotlinテストファイルはparse_errorとしてinvalidになる() {
1194 let source = FakeSource::new(&[("tests/Broken.kt", "class A { fun x() {} }\n")]);
1195 let graph = build_graph(&source, &config()).unwrap();
1196 assert!(graph
1197 .invalid
1198 .iter()
1199 .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("Broken.kt")));
1200 }
1201
1202 #[test]
1204 fn 構文エラーのjavaテストファイルはparse_errorとしてinvalidになる() {
1205 let source = FakeSource::new(&[(
1206 "tests/Broken.java",
1207 "public class A {\n @Test\n void t( {\n}\n",
1208 )]);
1209 let graph = build_graph(&source, &config()).unwrap();
1210 assert!(graph
1211 .invalid
1212 .iter()
1213 .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("Broken.java")));
1214 }
1215
1216 #[test]
1218 fn 構文エラーのtypescriptテストファイルはparse_errorとしてinvalidになる() {
1219 let source = FakeSource::new(&[("src/broken.test.ts", "it(\"a\", () => {\n")]);
1220 let graph = build_graph(&source, &config()).unwrap();
1221 assert!(graph
1222 .invalid
1223 .iter()
1224 .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("broken.test.ts")));
1225 }
1226
1227 #[test]
1229 fn csharp_1つのテストに2件以上のマーカーがあるとinvalidになる() {
1230 let source = FakeSource::new(&[(
1231 "tests/AuthTests.cs",
1232 "public class AuthTests\n{\n // [FR-001]\n // [FR-002]\n [Fact]\n public void T() { }\n}\n",
1233 )]);
1234 let graph = build_graph(&source, &config()).unwrap();
1235 assert!(graph
1236 .invalid
1237 .iter()
1238 .any(|i| i.reason == ReasonCode::MultipleMarkers));
1239 }
1240
1241 #[test]
1243 fn typescript_1つのテストに2件以上のマーカーがあるとinvalidになる() {
1244 let source = FakeSource::new(&[(
1245 "src/a.test.ts",
1246 "// [FR-001]\n// [FR-002]\nit(\"a\", () => {});\n",
1247 )]);
1248 let graph = build_graph(&source, &config()).unwrap();
1249 assert!(graph
1250 .invalid
1251 .iter()
1252 .any(|i| i.reason == ReasonCode::MultipleMarkers));
1253 }
1254
1255 #[test]
1257 fn testsのディレクトリ指定はcsharpとtypescriptのファイルにも適用される() {
1258 let source = FakeSource::new(&[
1259 REQ1,
1260 (
1261 "other/AuthTests.cs",
1262 "public class AuthTests\n{\n // [FR-001]\n [Fact]\n public void T() { }\n}\n",
1263 ),
1264 ("other/a.test.ts", "// [FR-001]\nit(\"a\", () => {});\n"),
1265 ]);
1266 let mut cfg = config();
1267 cfg.tests = vec!["tests".to_string()];
1268 let graph = build_graph(&source, &cfg).unwrap();
1269 assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Test));
1270 }
1271
1272 fn exclude_config(names: &[&str]) -> Config {
1273 let mut cfg = config();
1274 cfg.exclude = names.iter().map(|s| s.to_string()).collect();
1275 cfg
1276 }
1277
1278 const MARKED_JS: &str = "// [FR-001]\nit(\"works\", () => {});\n";
1279
1280 #[test]
1282 fn excludeに指定した名前のディレクトリ配下のtestはノードにならない() {
1283 let source = FakeSource::new(&[
1284 REQ1,
1285 ("build/a.test.js", MARKED_JS),
1286 ("src/a.test.js", MARKED_JS),
1287 ]);
1288 let graph = build_graph(&source, &exclude_config(&["build"])).unwrap();
1289 assert!(!has_test_node(&graph, "build/a.test::works"));
1290 assert!(has_test_node(&graph, "src/a.test::works"));
1291 }
1292
1293 #[test]
1295 fn excludeが空なら全ディレクトリのtestがノードになる() {
1296 let source = FakeSource::new(&[
1297 REQ1,
1298 ("build/a.test.js", MARKED_JS),
1299 ("src/a.test.js", MARKED_JS),
1300 ]);
1301 let graph = build_graph(&source, &exclude_config(&[])).unwrap();
1302 assert!(has_test_node(&graph, "build/a.test::works"));
1303 assert!(has_test_node(&graph, "src/a.test::works"));
1304 }
1305
1306 #[test]
1308 fn excludeはネストした深さのディレクトリにも適用される() {
1309 let source = FakeSource::new(&[
1310 REQ1,
1311 ("pkg/build/b.test.js", MARKED_JS),
1312 ("pkg/src/b.test.js", MARKED_JS),
1313 ]);
1314 let graph = build_graph(&source, &exclude_config(&["build"])).unwrap();
1315 assert!(!has_test_node(&graph, "pkg/build/b.test::works"));
1316 assert!(has_test_node(&graph, "pkg/src/b.test::works"));
1317 }
1318
1319 #[test]
1321 fn excludeに既定の除外名と重複する名前を指定しても問題ない() {
1322 let source = FakeSource::new(&[
1323 REQ1,
1324 ("dist/a.test.js", MARKED_JS),
1325 ("src/a.test.js", MARKED_JS),
1326 ]);
1327 let graph = build_graph(&source, &exclude_config(&["dist"])).unwrap();
1328 assert!(!has_test_node(&graph, "dist/a.test::works"));
1329 assert!(has_test_node(&graph, "src/a.test::works"));
1330 }
1331
1332 #[test]
1334 fn excludeは大文字小文字を区別する() {
1335 let source = FakeSource::new(&[REQ1, ("build/a.test.js", MARKED_JS)]);
1336 let graph = build_graph(&source, &exclude_config(&["Build"])).unwrap();
1337 assert!(has_test_node(&graph, "build/a.test::works"));
1338 }
1339
1340 #[test]
1342 fn exclude_nextのようなドット始まりの名前も除外できる() {
1343 let source = FakeSource::new(&[
1344 REQ1,
1345 (".next/a.test.js", MARKED_JS),
1346 ("src/a.test.js", MARKED_JS),
1347 ]);
1348 let graph = build_graph(&source, &exclude_config(&[".next"])).unwrap();
1349 assert!(!has_test_node(&graph, ".next/a.test::works"));
1350 assert!(has_test_node(&graph, "src/a.test::works"));
1351 }
1352
1353 #[test]
1355 fn 予約名のindex_mdとlog_mdはdocsでもrequirementsでもノードにならない() {
1356 let source = FakeSource::new(&[
1357 (
1358 "docs/index.md",
1359 "---\ntype: BusinessNeed\nid: DOC-X\n---\n[r](../req/fr1.md)\n",
1360 ),
1361 ("docs/log.md", "---\ntype: BusinessNeed\nid: DOC-L\n---\n"),
1362 ("docs/sub/index.md", "---\ntype: BusinessNeed\n---\n"),
1363 ("req/index.md", "---\ntype: Requirement\nid: FR-001\n---\n"),
1364 ("req/log.md", "---\ntype: Requirement\n---\n"),
1365 (
1366 "req/fr1.md",
1367 "---\ntype: Requirement\nid: FR-001\n---\n本文\n",
1368 ),
1369 ]);
1370 let graph = build_graph(&source, &config()).unwrap();
1371 assert!(
1372 graph.invalid.is_empty(),
1373 "reserved names must not be invalid: {:?}",
1374 graph.invalid
1375 );
1376 assert!(!graph.nodes.values().any(|n| n.key.kind == NodeKind::Doc));
1377 let reqs: Vec<&str> = graph
1378 .nodes
1379 .values()
1380 .filter(|n| n.key.kind == NodeKind::Req)
1381 .map(|n| n.path.as_str())
1382 .collect();
1383 assert_eq!(reqs, ["req/fr1.md"]);
1384 assert!(graph.edges.is_empty(), "{:?}", graph.edges);
1385 }
1386
1387 #[test]
1389 fn index未設定なら索引のfindingを一切出さない() {
1390 let source = FakeSource::new(&[
1391 ("docs/a.md", "---\ntype: BusinessNeed\nid: DOC-A\n---\n"),
1392 ("docs/untyped.md", "plain\n"),
1393 ]);
1394 let graph = build_graph(&source, &config()).unwrap();
1395 assert!(graph.index_issues.is_empty(), "{:?}", graph.index_issues);
1396 }
1397
1398 #[test]
1400 fn index設定があっても索引のtype_business_needはノードにならない() {
1401 let source = FakeSource::new(&[
1402 (
1403 "docs/index.md",
1404 "---\ntype: BusinessNeed\nid: DOC-X\n---\n- [A](a.md) — D\n",
1405 ),
1406 (
1407 "docs/a.md",
1408 "---\ntype: BusinessNeed\nid: DOC-A\ntitle: A\ndescription: D\n---\n",
1409 ),
1410 ]);
1411 let mut cfg = config();
1412 cfg.index = vec!["docs".to_string()];
1413 let graph = build_graph(&source, &cfg).unwrap();
1414 assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
1415 let docs: Vec<&str> = graph
1416 .nodes
1417 .values()
1418 .filter(|n| n.key.kind == NodeKind::Doc)
1419 .map(|n| n.path.as_str())
1420 .collect();
1421 assert_eq!(docs, ["docs/a.md"]);
1422 assert!(graph.index_issues.is_empty(), "{:?}", graph.index_issues);
1423 }
1424
1425 #[test]
1427 fn index設定があればbuild_graphが索引のfindingを集める() {
1428 let source = FakeSource::new(&[
1429 (
1430 "docs/a.md",
1431 "---\ntype: BusinessNeed\nid: DOC-A\ntitle: A\ndescription: D\n---\n",
1432 ),
1433 ("docs/untyped.md", "plain\n"),
1434 ]);
1435 let mut cfg = config();
1436 cfg.index = vec!["docs".to_string()];
1437 let graph = build_graph(&source, &cfg).unwrap();
1438 assert!(graph
1439 .index_issues
1440 .iter()
1441 .any(|i| i.reason == ReasonCode::MissingIndex && i.target == "docs"));
1442 assert!(graph
1443 .index_issues
1444 .iter()
1445 .any(|i| i.reason == ReasonCode::UntypedFile && i.target == "docs/untyped.md"));
1446 }
1447
1448 fn indexed_config(roots: &[&str]) -> Config {
1449 let mut cfg = config();
1450 cfg.index = roots.iter().map(|r| r.to_string()).collect();
1451 cfg
1452 }
1453
1454 fn desc_edges(graph: &crate::model::Graph) -> Vec<&crate::model::Edge> {
1455 graph
1456 .edges
1457 .values()
1458 .filter(|e| e.kind == crate::model::EdgeKind::Desc)
1459 .collect()
1460 }
1461
1462 const DOC_A: &str = "---\ntype: BusinessNeed\nid: DOC-A\ndescription: 要約A\n---\n本文A\n";
1463 const REQ_1: &str = "---\ntype: Requirement\nid: FR-001\ndescription: 要約1\n---\n本文1\n";
1464
1465 #[test]
1467 fn index配下のdocとreqにdescエッジが1本ずつできる() {
1468 let source = FakeSource::new(&[("docs/a.md", DOC_A), ("docs/r.md", REQ_1)]);
1469 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1470 let doc = graph.edges.get("desc:doc:DOC-A").expect("desc:doc:DOC-A");
1471 assert_eq!(doc.kind, crate::model::EdgeKind::Desc);
1472 assert_eq!(doc.from, doc.to);
1473 assert_eq!(doc.from.kind, NodeKind::Doc);
1474 assert_eq!(doc.from.id, "DOC-A");
1475 let req = graph.edges.get("desc:req:FR-001").expect("desc:req:FR-001");
1476 assert_eq!(req.kind, crate::model::EdgeKind::Desc);
1477 assert_eq!(req.from, req.to);
1478 assert_eq!(req.from.kind, NodeKind::Req);
1479 assert_eq!(desc_edges(&graph).len(), 2, "{:?}", graph.edges);
1480 }
1481
1482 #[test]
1484 fn descエッジはdescriptionが無いノードにもできる() {
1485 let source = FakeSource::new(&[(
1486 "docs/a.md",
1487 "---\ntype: BusinessNeed\nid: DOC-A\n---\n本文A\n",
1488 )]);
1489 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1490 assert!(
1491 graph.edges.contains_key("desc:doc:DOC-A"),
1492 "{:?}",
1493 graph.edges
1494 );
1495 }
1496
1497 #[test]
1499 fn descエッジは空や改行を含むdescriptionのノードにもできる() {
1500 let source = FakeSource::new(&[
1501 (
1502 "docs/a.md",
1503 "---\ntype: BusinessNeed\nid: DOC-A\ndescription: \"\"\n---\n本文\n",
1504 ),
1505 (
1506 "docs/b.md",
1507 "---\ntype: BusinessNeed\nid: DOC-B\ndescription: |\n 一行目\n 二行目\n---\n本文\n",
1508 ),
1509 ]);
1510 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1511 assert!(
1512 graph.edges.contains_key("desc:doc:DOC-A"),
1513 "{:?}",
1514 graph.edges
1515 );
1516 assert!(
1517 graph.edges.contains_key("desc:doc:DOC-B"),
1518 "{:?}",
1519 graph.edges
1520 );
1521 }
1522
1523 #[test]
1525 fn indexが空ならdescエッジは1本もできない() {
1526 let source = FakeSource::new(&[("docs/a.md", DOC_A), ("docs/r.md", REQ_1)]);
1527 let graph = build_graph(&source, &config()).unwrap();
1528 assert!(desc_edges(&graph).is_empty(), "{:?}", graph.edges);
1529 }
1530
1531 #[test]
1533 fn 索引の根の外にあるreqにはdescエッジができない() {
1534 let source = FakeSource::new(&[("docs/a.md", DOC_A), ("req/fr1.md", REQ_1)]);
1535 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1536 assert!(graph.edges.contains_key("desc:doc:DOC-A"));
1537 assert!(
1538 !graph.edges.contains_key("desc:req:FR-001"),
1539 "{:?}",
1540 graph.edges
1541 );
1542 }
1543
1544 #[test]
1546 fn 索引の根の配下にあるreqにはdescエッジができる() {
1547 let source = FakeSource::new(&[("docs/a.md", DOC_A), ("req/fr1.md", REQ_1)]);
1548 let graph = build_graph(&source, &indexed_config(&["docs", "req"])).unwrap();
1549 assert!(
1550 graph.edges.contains_key("desc:req:FR-001"),
1551 "{:?}",
1552 graph.edges
1553 );
1554 }
1555
1556 #[test]
1558 fn 無効なidのノードにはdescエッジができない() {
1559 let source = FakeSource::new(&[
1560 (
1561 "docs/bad.md",
1562 "---\ntype: BusinessNeed\nid: BAD ID\ndescription: D\n---\n本文\n",
1563 ),
1564 (
1565 "docs/noid.md",
1566 "---\ntype: BusinessNeed\ndescription: D\n---\n本文\n",
1567 ),
1568 ]);
1569 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1570 assert!(!graph.invalid.is_empty());
1571 assert!(desc_edges(&graph).is_empty(), "{:?}", graph.edges);
1572 }
1573
1574 #[test]
1576 fn guideなどmiwaのノードでない文書にはdescエッジができない() {
1577 let source = FakeSource::new(&[(
1578 "docs/g.md",
1579 "---\ntype: Guide\nid: GUIDE-1\ndescription: D\n---\n本文\n",
1580 )]);
1581 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1582 assert!(desc_edges(&graph).is_empty(), "{:?}", graph.edges);
1583 }
1584
1585 #[test]
1587 fn descエッジはorphanとno_testの判定に影響しない() {
1588 let source = FakeSource::new(&[("docs/r.md", REQ_1), ("docs/a.md", DOC_A)]);
1589 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1590 let findings = crate::check::check(&graph, &crate::lock::Lock::default());
1591 assert!(findings
1592 .iter()
1593 .any(|f| f.kind == crate::check::FindingKind::Orphan
1594 && f.reason == ReasonCode::ReqWithoutDoc
1595 && f.target == "FR-001"));
1596 assert!(findings
1597 .iter()
1598 .any(|f| f.kind == crate::check::FindingKind::NoTest && f.target == "FR-001"));
1599 assert!(findings
1600 .iter()
1601 .any(|f| f.kind == crate::check::FindingKind::Orphan
1602 && f.reason == ReasonCode::DocWithoutReq
1603 && f.target == "DOC-A"));
1604 }
1605
1606 #[test]
1608 fn ロックにdescエッジがありノードが無ければremovedになる() {
1609 let source = FakeSource::new(&[("docs/a.md", DOC_A)]);
1610 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1611 let mut lock = crate::lock::Lock::default();
1612 lock.edges.insert(
1613 "desc:doc:DOC-GONE".to_string(),
1614 crate::lock::LockEntry {
1615 from: "sha256:x".to_string(),
1616 to: "sha256:y".to_string(),
1617 by: "human".to_string(),
1618 reason: None,
1619 score: None,
1620 at: "2026-01-01T00:00:00Z".to_string(),
1621 },
1622 );
1623 let findings = crate::check::check(&graph, &lock);
1624 assert!(findings
1625 .iter()
1626 .any(|f| f.kind == crate::check::FindingKind::Removed
1627 && f.target == "desc:doc:DOC-GONE"
1628 && f.reason == ReasonCode::RemovedEdge));
1629 }
1630
1631 #[test]
1633 fn indexの根から外れるとロックのdescエッジはremovedになる() {
1634 let source = FakeSource::new(&[("docs/a.md", DOC_A)]);
1635 let with_index = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1636 let mut lock = crate::lock::Lock::default();
1637 let (from, to) =
1638 crate::model::edge_hashes(&with_index, &with_index.edges["desc:doc:DOC-A"]);
1639 lock.edges.insert(
1640 "desc:doc:DOC-A".to_string(),
1641 crate::lock::LockEntry {
1642 from,
1643 to,
1644 by: "human".to_string(),
1645 reason: None,
1646 score: None,
1647 at: "2026-01-01T00:00:00Z".to_string(),
1648 },
1649 );
1650 let without_index = build_graph(&source, &config()).unwrap();
1651 let findings = crate::check::check(&without_index, &lock);
1652 assert!(findings
1653 .iter()
1654 .any(|f| f.kind == crate::check::FindingKind::Removed && f.target == "desc:doc:DOC-A"));
1655 }
1656
1657 fn desc_hashes(graph: &crate::model::Graph, id: &str) -> (String, String) {
1658 crate::model::edge_hashes(graph, &graph.edges[id])
1659 }
1660
1661 #[test]
1663 fn descエッジのfromは要約_toは本文のハッシュになる() {
1664 let source = FakeSource::new(&[("docs/a.md", DOC_A)]);
1665 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1666 assert_eq!(
1667 desc_hashes(&graph, "desc:doc:DOC-A"),
1668 (
1669 crate::hash::content_hash("要約A"),
1670 crate::hash::content_hash("本文A\n")
1671 )
1672 );
1673 }
1674
1675 #[test]
1677 fn descエッジのfromは前後の空白を除いた要約のハッシュになる() {
1678 let source = FakeSource::new(&[(
1679 "docs/a.md",
1680 "---\ntype: BusinessNeed\nid: DOC-A\ndescription: \" 要約A \"\n---\n本文A\n",
1681 )]);
1682 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1683 assert_eq!(
1684 desc_hashes(&graph, "desc:doc:DOC-A").0,
1685 crate::hash::content_hash("要約A")
1686 );
1687 }
1688
1689 #[test]
1691 fn descエッジのfromは要約が無い_空_改行を含むときは空文字列のハッシュになる() {
1692 let empty = crate::hash::content_hash("");
1693 for fm in [
1694 "type: BusinessNeed\nid: DOC-A",
1695 "type: BusinessNeed\nid: DOC-A\ndescription: \"\"",
1696 "type: BusinessNeed\nid: DOC-A\ndescription: |\n 一行目\n 二行目",
1697 ] {
1698 let source = FakeSource::new(&[("docs/a.md", &format!("---\n{fm}\n---\n本文\n"))]);
1699 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1700 assert_eq!(desc_hashes(&graph, "desc:doc:DOC-A").0, empty, "{fm}");
1701 }
1702 }
1703
1704 #[test]
1706 fn descエッジのfromは折り畳みスカラーの要約を1行に畳んだハッシュになる() {
1707 let source = FakeSource::new(&[(
1708 "docs/a.md",
1709 "---\ntype: BusinessNeed\nid: DOC-A\ndescription: >\n 一行目\n 二行目\n---\n本文\n",
1710 )]);
1711 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1712 assert_eq!(
1713 desc_hashes(&graph, "desc:doc:DOC-A").0,
1714 crate::hash::content_hash("一行目 二行目")
1715 );
1716 }
1717
1718 #[test]
1720 fn 同じidのdocとreqは別々のdescエッジになる() {
1721 let source = FakeSource::new(&[
1722 (
1723 "docs/a.md",
1724 "---\ntype: BusinessNeed\nid: X-1\ndescription: 要約D\n---\n本文D\n",
1725 ),
1726 (
1727 "docs/r.md",
1728 "---\ntype: Requirement\nid: X-1\ndescription: 要約R\n---\n本文R\n",
1729 ),
1730 ]);
1731 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1732 assert!(
1733 graph.edges.contains_key("desc:doc:X-1"),
1734 "{:?}",
1735 graph.edges
1736 );
1737 assert!(
1738 graph.edges.contains_key("desc:req:X-1"),
1739 "{:?}",
1740 graph.edges
1741 );
1742 assert_eq!(desc_edges(&graph).len(), 2, "{:?}", graph.edges);
1743 assert_eq!(
1744 desc_hashes(&graph, "desc:doc:X-1").0,
1745 crate::hash::content_hash("要約D")
1746 );
1747 assert_eq!(
1748 desc_hashes(&graph, "desc:req:X-1").0,
1749 crate::hash::content_hash("要約R")
1750 );
1751 }
1752
1753 #[test]
1755 fn 要約だけを変えてもdoc_reqとtest_reqのハッシュは変わらずfindingも出ない() {
1756 let build = |description: &str| {
1757 let source = FakeSource::new(&[
1758 (
1759 "docs/a.md",
1760 &format!(
1761 "---\ntype: BusinessNeed\nid: DOC-A\ndescription: {description}\n---\n[r](../req/fr1.md)\n"
1762 ),
1763 ),
1764 (
1765 "req/fr1.md",
1766 "---\ntype: Requirement\nid: FR-001\n---\n本文1\n",
1767 ),
1768 ("tests/t.rs", "// [FR-001]\n#[test]\nfn t() {}\n"),
1769 ]);
1770 build_graph(&source, &indexed_config(&["docs"])).unwrap()
1771 };
1772 let old = build("旧要約");
1773 let new = build("新要約");
1774 let test_edge = old
1775 .edges
1776 .keys()
1777 .find(|k| k.starts_with("test-req:"))
1778 .cloned()
1779 .expect("test-req edge");
1780 for id in ["doc-req:DOC-A:FR-001", test_edge.as_str()] {
1781 assert_eq!(desc_hashes(&old, id), desc_hashes(&new, id), "{id}");
1782 }
1783 assert_ne!(
1784 desc_hashes(&old, "desc:doc:DOC-A").0,
1785 desc_hashes(&new, "desc:doc:DOC-A").0
1786 );
1787 let mut lock = crate::lock::Lock::default();
1788 for (id, edge) in &old.edges {
1789 let (from, to) = crate::model::edge_hashes(&old, edge);
1790 lock.edges.insert(
1791 id.clone(),
1792 crate::lock::LockEntry {
1793 from,
1794 to,
1795 by: "human".to_string(),
1796 reason: None,
1797 score: None,
1798 at: "2026-01-01T00:00:00Z".to_string(),
1799 },
1800 );
1801 }
1802 let drifts: Vec<String> = crate::check::check(&new, &lock)
1803 .into_iter()
1804 .filter(|f| f.kind == crate::check::FindingKind::Drift)
1805 .map(|f| f.target)
1806 .collect();
1807 assert_eq!(drifts, vec!["desc:doc:DOC-A".to_string()]);
1808 }
1809}