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 TestLanguage::Dart => crate::extract::extract_dart_tests(path, &text),
247 };
248 match result {
249 Ok(tests) => {
250 extracted_tests.extend(
251 tests
252 .into_iter()
253 .filter(|t| !t.markers.is_empty())
254 .map(|t| (path.clone(), t)),
255 );
256 }
257 Err(_e) => {
258 graph.invalid.push(Invalid {
259 target_kind: TargetKind::File,
260 target: path.clone(),
261 reason: ReasonCode::ParseError,
262 detail: format!("failed to parse {path}"),
263 });
264 }
265 }
266 }
267
268 let mut by_test_id: BTreeMap<String, Vec<(String, crate::extract::ExtractedTest)>> =
272 BTreeMap::new();
273 for (path, t) in extracted_tests {
274 if t.markers.len() > 1 {
275 graph.invalid.push(Invalid {
276 target_kind: TargetKind::Test,
277 target: t.id.clone(),
278 reason: ReasonCode::MultipleMarkers,
279 detail: format!("{} has multiple markers: {:?}", t.id, t.markers),
280 });
281 continue;
282 }
283 by_test_id.entry(t.id.clone()).or_default().push((path, t));
284 }
285
286 for (test_id, occurrences) in by_test_id {
287 let req_ids: std::collections::BTreeSet<&String> =
288 occurrences.iter().map(|(_, t)| &t.markers[0]).collect();
289 if req_ids.len() > 1 {
290 let paths: BTreeSet<&str> = occurrences.iter().map(|(p, _)| p.as_str()).collect();
291 let reqs: Vec<&str> = req_ids.iter().map(|r| r.as_str()).collect();
292 graph.invalid.push(Invalid {
293 target_kind: TargetKind::Test,
294 target: test_id.clone(),
295 reason: ReasonCode::DuplicateId,
296 detail: format!(
297 "duplicate test id {test_id} points at different requirements ({}) at {}",
298 reqs.join(", "),
299 paths.into_iter().collect::<Vec<_>>().join(", ")
300 ),
301 });
302 continue;
303 }
304 let req_id = occurrences[0].1.markers[0].clone();
305 let path = occurrences[0].0.clone();
306 let content = occurrences
307 .iter()
308 .map(|(_, t)| t.content.as_str())
309 .collect::<Vec<_>>()
310 .join("\n");
311 let test_key = NodeKey {
312 kind: NodeKind::Test,
313 id: test_id.clone(),
314 };
315 let hash = crate::hash::content_hash(&content);
316 graph.nodes.insert(
317 test_key.clone(),
318 Node {
319 key: test_key.clone(),
320 path,
321 content,
322 hash,
323 description: None,
324 },
325 );
326 let req_key = NodeKey {
327 kind: NodeKind::Req,
328 id: req_id.clone(),
329 };
330 if graph.nodes.contains_key(&req_key) {
331 let id = edge_id(EdgeKind::TestReq, &test_id, &req_id);
332 graph.edges.insert(
333 id.clone(),
334 Edge {
335 id,
336 kind: EdgeKind::TestReq,
337 from: test_key,
338 to: req_key,
339 },
340 );
341 } else {
342 graph.broken.push(Broken {
343 from: test_key,
344 target: req_id.clone(),
345 reason: ReasonCode::MissingReq,
346 detail: format!("{req_id} does not exist"),
347 });
348 }
349 }
350
351 if !config.index.is_empty() {
353 graph.index_issues = crate::index::collect_issues(config, &files, &texts)
354 .into_iter()
355 .collect();
356 }
357
358 graph.invalid.sort();
359 graph.broken.sort();
360 Ok(graph)
361}
362
363fn is_under_any(path: &str, dirs: &[String]) -> bool {
364 dirs.iter()
365 .any(|d| crate::config::is_under(path, &crate::config::normalize_dir(d)))
366}
367
368#[derive(Debug, Clone, Copy)]
369enum TestLanguage {
370 Go,
371 Rust,
372 CSharp,
373 Java,
374 Kotlin,
375 TypeScript,
376 Dart,
377}
378
379fn test_language(path: &str) -> Option<TestLanguage> {
382 if path.ends_with("_test.go") {
383 Some(TestLanguage::Go)
384 } else if path.ends_with(".rs") {
385 Some(TestLanguage::Rust)
386 } else if path.ends_with(".cs") {
387 Some(TestLanguage::CSharp)
388 } else if path.ends_with(".java") {
389 Some(TestLanguage::Java)
390 } else if path.ends_with(".kt") {
391 Some(TestLanguage::Kotlin)
392 } else if crate::extract::is_typescript_test_file(path) {
393 Some(TestLanguage::TypeScript)
394 } else if crate::extract::is_dart_test_file(path) {
395 Some(TestLanguage::Dart)
396 } else {
397 None
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::config::{Config, JudgeConfig};
405 use crate::model::{NodeKind, ReasonCode, TargetKind};
406 use crate::source::Source;
407 use std::collections::BTreeMap;
408
409 struct FakeSource {
410 files: BTreeMap<String, Vec<u8>>,
411 }
412
413 impl FakeSource {
414 fn new(files: &[(&str, &str)]) -> FakeSource {
415 FakeSource {
416 files: files
417 .iter()
418 .map(|(p, c)| (p.to_string(), c.as_bytes().to_vec()))
419 .collect(),
420 }
421 }
422 }
423
424 impl Source for FakeSource {
425 fn list_files(&self) -> anyhow::Result<Vec<String>> {
426 Ok(self.files.keys().cloned().collect())
427 }
428
429 fn read(&self, path: &str) -> anyhow::Result<Vec<u8>> {
430 self.files
431 .get(path)
432 .cloned()
433 .ok_or_else(|| anyhow::anyhow!("not found: {path}"))
434 }
435 }
436
437 fn config() -> Config {
438 Config {
439 docs: vec!["docs".to_string()],
440 requirements: vec!["req".to_string()],
441 tests: vec![".".to_string()],
442 diff_base: "main".to_string(),
443 judge: JudgeConfig {
444 threshold: 0.9,
445 max_body_bytes: 200_000,
446 },
447 rust: crate::config::RustConfig::default(),
448 exclude: vec![],
449 index: vec![],
450 site: None,
451 }
452 }
453
454 #[test]
456 fn typeフィールドのないmdファイルはノードとして扱われない() {
457 let source = FakeSource::new(&[
458 ("docs/a.md", "# no frontmatter\nplain text\n"),
459 (
460 "req/fr1.md",
461 "---\ntype: Requirement\nid: FR-001\n---\n本文\n",
462 ),
463 ]);
464 let graph = build_graph(&source, &config()).unwrap();
465 assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Doc));
466 assert!(
467 graph.invalid.is_empty(),
468 "should be silently excluded, not invalid: {:?}",
469 graph.invalid
470 );
471 }
472
473 #[test]
475 fn typeの値がbusiness_need_requirement以外なら対象外になる() {
476 let source = FakeSource::new(&[
477 ("docs/b.md", "---\ntype: Note\nid: DOC-B\n---\n本文\n"),
478 (
479 "req/fr1.md",
480 "---\ntype: Requirement\nid: FR-001\n---\n本文\n",
481 ),
482 ]);
483 let graph = build_graph(&source, &config()).unwrap();
484 assert!(!graph.nodes.values().any(|n| n.key.id == "DOC-B"));
485 }
486
487 #[test]
489 fn type_specificationの文書はdocノードにならない() {
490 let source = FakeSource::new(&[
491 (
492 "docs/old.md",
493 "---\ntype: Specification\nid: DOC-OLD\n---\n本文\n",
494 ),
495 (
496 "req/fr1.md",
497 "---\ntype: Requirement\nid: FR-001\n---\n本文\n",
498 ),
499 ]);
500 let graph = build_graph(&source, &config()).unwrap();
501 assert!(!graph.nodes.values().any(|n| n.key.id == "DOC-OLD"));
502 assert!(
503 graph.invalid.is_empty(),
504 "should be silently excluded, not invalid: {:?}",
505 graph.invalid
506 );
507 }
508
509 #[test]
511 fn 不正な文字を含むidはinvalid_idとして報告される() {
512 let source = FakeSource::new(&[(
513 "req/bad.md",
514 "---\ntype: Requirement\nid: FR:001\n---\n本文\n",
515 )]);
516 let graph = build_graph(&source, &config()).unwrap();
517 assert!(graph
518 .invalid
519 .iter()
520 .any(|i| i.reason == ReasonCode::InvalidId && i.target_kind == TargetKind::Req));
521 }
522
523 #[test]
525 fn idが空のファイルはmissing_idとして報告される() {
526 let source = FakeSource::new(&[(
527 "req/empty-id.md",
528 "---\ntype: Requirement\nid: \n---\n本文\n",
529 )]);
530 let graph = build_graph(&source, &config()).unwrap();
531 assert!(graph
532 .invalid
533 .iter()
534 .any(|i| i.reason == ReasonCode::MissingId));
535 }
536
537 #[test]
539 fn 同じidを持つファイルが複数あるとduplicate_idとして報告される() {
540 let source = FakeSource::new(&[
541 (
542 "req/fr1.md",
543 "---\ntype: Requirement\nid: FR-001\n---\n本文A\n",
544 ),
545 (
546 "req/fr1-dup.md",
547 "---\ntype: Requirement\nid: FR-001\n---\n本文B\n",
548 ),
549 ]);
550 let graph = build_graph(&source, &config()).unwrap();
551 assert!(graph
552 .invalid
553 .iter()
554 .any(|i| i.reason == ReasonCode::DuplicateId && i.target == "FR-001"));
555 }
556
557 #[test]
559 fn requirements領域外を指すリンクはエッジにもbrokenにもならない() {
560 let source = FakeSource::new(&[
561 (
562 "docs/a.md",
563 "---\ntype: BusinessNeed\nid: DOC-A\n---\n[外部req](../outside/x.md)\n",
564 ),
565 (
566 "outside/x.md",
567 "---\ntype: Requirement\nid: FR-999\n---\n本文\n",
568 ),
569 ]);
570 let graph = build_graph(&source, &config()).unwrap();
571 assert!(graph
572 .edges
573 .values()
574 .all(|e| e.kind != crate::model::EdgeKind::DocReq));
575 assert!(
576 graph.broken.is_empty(),
577 "should not be reported as broken: {:?}",
578 graph.broken
579 );
580 }
581
582 #[test]
584 fn 存在しないreqファイルを指すリンクはbrokenになる() {
585 let source = FakeSource::new(&[(
586 "docs/a.md",
587 "---\ntype: BusinessNeed\nid: DOC-A\n---\n[リンク](../req/missing.md)\n",
588 )]);
589 let graph = build_graph(&source, &config()).unwrap();
590 assert!(graph
591 .broken
592 .iter()
593 .any(|b| b.from.kind == NodeKind::Doc && b.reason == ReasonCode::MissingReq));
594 }
595
596 #[test]
598 fn requirement型でないファイルを指すリンクはnot_req_fileとしてbrokenになる() {
599 let source = FakeSource::new(&[
600 (
601 "docs/a.md",
602 "---\ntype: BusinessNeed\nid: DOC-A\n---\n[リンク](../req/not-a-req.md)\n",
603 ),
604 (
605 "req/not-a-req.md",
606 "---\ntype: BusinessNeed\nid: DOC-FAKE\n---\n本文\n",
607 ),
608 ]);
609 let graph = build_graph(&source, &config()).unwrap();
610 assert!(graph
611 .broken
612 .iter()
613 .any(|b| b.from.kind == NodeKind::Doc && b.reason == ReasonCode::NotReqFile));
614 }
615
616 #[test]
618 fn 存在しないreq_idを指すtestマーカーはbrokenになる() {
619 let source = FakeSource::new(&[(
620 "src/foo.rs",
621 "// [FR-999]\n#[test]\nfn does_something() {}\n",
622 )]);
623 let graph = build_graph(&source, &config()).unwrap();
624 assert!(graph
625 .broken
626 .iter()
627 .any(|b| b.from.kind == NodeKind::Test && b.reason == ReasonCode::MissingReq));
628 }
629
630 #[test]
632 fn go_1つのテストに2件以上のマーカーがあるとinvalidになる() {
633 let source = FakeSource::new(&[(
634 "main_test.go",
635 "package main\n\n// [FR-001]\n// [FR-002]\nfunc TestBoth(t *testing.T) {}\n",
636 )]);
637 let graph = build_graph(&source, &config()).unwrap();
638 assert!(graph
639 .invalid
640 .iter()
641 .any(|i| i.reason == ReasonCode::MultipleMarkers));
642 }
643
644 #[test]
646 fn 構文エラーのrustテストファイルはparse_errorとしてinvalidになる() {
647 let source = FakeSource::new(&[("broken.rs", "fn totally #%^& invalid syntax {\n")]);
648 let graph = build_graph(&source, &config()).unwrap();
649 assert!(graph
650 .invalid
651 .iter()
652 .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("broken.rs")));
653 }
654
655 #[test]
657 fn rust_1つのテストに2件以上のマーカーがあるとinvalidになる() {
658 let source = FakeSource::new(&[(
659 "src/foo.rs",
660 "// [FR-001]\n// [FR-002]\n#[test]\nfn does_both() {}\n",
661 )]);
662 let graph = build_graph(&source, &config()).unwrap();
663 assert!(graph
664 .invalid
665 .iter()
666 .any(|i| i.reason == ReasonCode::MultipleMarkers));
667 }
668
669 #[test]
671 fn 構文エラーのgoテストファイルはparse_errorとしてinvalidになる() {
672 let source = FakeSource::new(&[("broken_test.go", "package main\n\nfunc TestBroken( {\n")]);
673 let graph = build_graph(&source, &config()).unwrap();
674 assert!(graph
675 .invalid
676 .iter()
677 .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("broken_test.go")));
678 }
679
680 #[test]
682 fn configのディレクトリ表記がドットスラッシュや末尾スラッシュ付きでも拾われる() {
683 let source = FakeSource::new(&[
684 (
685 "docs/spec.md",
686 "---\ntype: BusinessNeed\nid: DOC-A\n---\n[リンク](../req/fr1.md)\n",
687 ),
688 (
689 "req/fr1.md",
690 "---\ntype: Requirement\nid: FR-001\n---\n本文\n",
691 ),
692 ]);
693 let config = Config {
694 docs: vec!["./docs".to_string()],
695 requirements: vec!["req/".to_string()],
696 tests: vec![".".to_string()],
697 diff_base: "main".to_string(),
698 judge: JudgeConfig {
699 threshold: 0.9,
700 max_body_bytes: 200_000,
701 },
702 rust: crate::config::RustConfig::default(),
703 exclude: vec![],
704 index: vec![],
705 site: None,
706 };
707 let graph = build_graph(&source, &config).unwrap();
708 assert!(graph
709 .nodes
710 .keys()
711 .any(|k| k.kind == NodeKind::Doc && k.id == "DOC-A"));
712 assert!(graph
713 .nodes
714 .keys()
715 .any(|k| k.kind == NodeKind::Req && k.id == "FR-001"));
716 assert!(graph.edges.contains_key("doc-req:DOC-A:FR-001"));
717 }
718
719 const REQ1: (&str, &str) = (
720 "req/fr1.md",
721 "---\ntype: Requirement\nid: FR-001\n---\n本文\n",
722 );
723
724 fn has_test_node(graph: &crate::model::Graph, id: &str) -> bool {
725 graph
726 .nodes
727 .keys()
728 .any(|k| k.kind == NodeKind::Test && k.id == id)
729 }
730
731 #[test]
733 fn csharpファイルからtestノードとエッジが作られる() {
734 let source = FakeSource::new(&[
735 REQ1,
736 (
737 "tests/AuthTests.cs",
738 "public class AuthTests\n{\n // [FR-001]\n [Fact]\n public void T() { }\n}\n",
739 ),
740 ]);
741 let graph = build_graph(&source, &config()).unwrap();
742 assert!(has_test_node(&graph, "tests/AuthTests:AuthTests:T"));
743 assert!(graph
744 .edges
745 .contains_key("test-req:tests/AuthTests:AuthTests:T:FR-001"));
746 }
747
748 #[test]
750 fn configのrust_test_attributesが抽出に反映される() {
751 let src = "// [FR-001]\n#[rstest]\nfn t() {}\n";
752 let source = FakeSource::new(&[REQ1, ("src/a.rs", src)]);
753
754 let graph = build_graph(&source, &config()).unwrap();
755 assert!(!has_test_node(&graph, "src/a::t"));
756
757 let mut cfg = config();
758 cfg.rust.test_attributes = vec!["rstest".to_string()];
759 let graph = build_graph(&source, &cfg).unwrap();
760 assert!(has_test_node(&graph, "src/a::t"));
761 assert!(graph.edges.contains_key("test-req:src/a::t:FR-001"));
762 }
763
764 #[test]
766 fn typescriptのtest_tsとspec_tsxからtestノードが作られる() {
767 let source = FakeSource::new(&[
768 REQ1,
769 ("src/a.test.ts", "// [FR-001]\nit(\"works\", () => {});\n"),
770 (
771 "src/b.spec.tsx",
772 "// [FR-001]\nit(\"renders\", () => { const x = <div />; });\n",
773 ),
774 ]);
775 let graph = build_graph(&source, &config()).unwrap();
776 assert!(has_test_node(&graph, "src/a.test::works"));
777 assert!(has_test_node(&graph, "src/b.spec::renders"));
778 }
779
780 #[test]
782 fn dartのtest_dartからtestノードが作られ通常のdartファイルは走査されない() {
783 let source = FakeSource::new(&[
784 REQ1,
785 (
786 "src/a_test.dart",
787 "void main() {
788 group('g', () {
789 // [FR-001]
790 test('works', () {});
791 });
792}
793",
794 ),
795 (
796 "src/helper.dart",
797 "void main() {
798 // [FR-001]
799 test('skipped', () {});
800}
801",
802 ),
803 ]);
804 let graph = build_graph(&source, &config()).unwrap();
805 assert!(has_test_node(&graph, "src/a_test:g:works"));
806 assert!(!has_test_node(&graph, "src/helper::skipped"));
807 assert!(graph
808 .edges
809 .contains_key("test-req:src/a_test:g:works:FR-001"));
810 }
811
812 #[test]
814 fn typescriptのtestやspecを含まない通常のtsファイルは走査されない() {
815 let source = FakeSource::new(&[
816 REQ1,
817 ("src/a.ts", "// [FR-001]\nit(\"works\", () => {});\n"),
818 ("src/b.tsx", "// [FR-001]\nit(\"works\", () => {});\n"),
819 ]);
820 let graph = build_graph(&source, &config()).unwrap();
821 assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Test));
822 assert!(graph.invalid.is_empty());
823 }
824
825 #[test]
827 fn javascriptのtest_jsとspec_mjsからtestノードが作られる() {
828 let source = FakeSource::new(&[
829 REQ1,
830 ("src/a.test.js", "// [FR-001]\nit(\"works\", () => {});\n"),
831 ("src/b.spec.mjs", "// [FR-001]\nit(\"loads\", () => {});\n"),
832 ]);
833 let graph = build_graph(&source, &config()).unwrap();
834 assert!(has_test_node(&graph, "src/a.test::works"));
835 assert!(has_test_node(&graph, "src/b.spec::loads"));
836 }
837
838 #[test]
840 fn testやspecを含まない通常のjsファイルは走査されない() {
841 let source = FakeSource::new(&[
842 REQ1,
843 ("src/a.js", "// [FR-001]\nit(\"works\", () => {});\n"),
844 ]);
845 let graph = build_graph(&source, &config()).unwrap();
846 assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Test));
847 assert!(graph.invalid.is_empty());
848 }
849
850 #[test]
852 fn 構文エラーのjsテストファイルはparse_errorとしてinvalidになる() {
853 let source = FakeSource::new(&[("src/broken.test.js", "it(\"t\", () => {\n")]);
854 let graph = build_graph(&source, &config()).unwrap();
855 assert!(graph
856 .invalid
857 .iter()
858 .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("broken.test.js")));
859 }
860
861 const REQ2: (&str, &str) = (
862 "req/fr2.md",
863 "---\ntype: Requirement\nid: FR-002\n---\n本文2\n",
864 );
865
866 fn test_node<'a>(graph: &'a crate::model::Graph, id: &str) -> &'a crate::model::Node {
867 graph
868 .nodes
869 .values()
870 .find(|n| n.key.kind == NodeKind::Test && n.key.id == id)
871 .unwrap_or_else(|| panic!("test node {id} not found"))
872 }
873
874 fn cs_overloads(first: &str, second: &str) -> String {
875 format!(
876 "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"
877 )
878 }
879
880 #[test]
882 fn csharpのオーバーロードは1つのtestにまとめられcontentが出現順に連結される() {
883 let source = FakeSource::new(&[
884 REQ1,
885 ("tests/AuthTests.cs", &cs_overloads("one();", "two();")),
886 ]);
887 let graph = build_graph(&source, &config()).unwrap();
888 assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
889 let tests: Vec<_> = graph
890 .nodes
891 .keys()
892 .filter(|k| k.kind == NodeKind::Test)
893 .collect();
894 assert_eq!(tests.len(), 1, "{tests:?}");
895 let edges: Vec<_> = graph
896 .edges
897 .values()
898 .filter(|e| e.kind == crate::model::EdgeKind::TestReq)
899 .collect();
900 assert_eq!(edges.len(), 1, "{edges:?}");
901 let node = test_node(&graph, "tests/AuthTests:AuthTests:T");
902 let a = node.content.find("one();").expect("first occurrence");
903 let b = node.content.find("two();").expect("second occurrence");
904 assert!(a < b, "{:?}", node.content);
905 let between = &node.content[a..b];
906 assert!(between.contains('\n'), "{:?}", node.content);
907 assert!(node.content.contains("public void T()"));
908 assert!(node.content.contains("public void T(int x)"));
909 }
910
911 #[test]
913 fn goの別ファイルにある同名testはファイルパス順に連結される() {
914 let source = FakeSource::new(&[
915 REQ1,
916 (
917 "pkg/b_test.go",
918 "package pkg\n\n// [FR-001]\nfunc TestX(t *testing.T) { second_body() }\n",
919 ),
920 (
921 "pkg/a_test.go",
922 "package pkg\n\n// [FR-001]\nfunc TestX(t *testing.T) { first_body() }\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, "pkg:TestX");
928 let a = node.content.find("first_body").expect("a_test.go");
929 let b = node.content.find("second_body").expect("b_test.go");
930 assert!(a < b, "{:?}", node.content);
931 }
932
933 #[test]
935 fn rustのcfg分岐による同名testは1つのtestにまとめられる() {
936 let source = FakeSource::new(&[
937 REQ1,
938 (
939 "src/lib.rs",
940 "#[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",
941 ),
942 ]);
943 let graph = build_graph(&source, &config()).unwrap();
944 assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
945 let node = test_node(&graph, "src/lib::same");
946 let a = node.content.find("unix_body").expect("unix");
947 let b = node.content.find("windows_body").expect("windows");
948 assert!(a < b, "{:?}", node.content);
949 }
950
951 #[test]
953 fn typescriptの同名testは1つのtestにまとめられる() {
954 let source = FakeSource::new(&[
955 REQ1,
956 (
957 "src/a.test.ts",
958 "// [FR-001]\nit(\"same\", () => { first(); });\n\n// [FR-001]\nit(`same`, () => { second(); });\n",
959 ),
960 ]);
961 let graph = build_graph(&source, &config()).unwrap();
962 assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
963 let node = test_node(&graph, "src/a.test::same");
964 assert!(node.content.contains("first();"));
965 assert!(node.content.contains("second();"));
966 }
967
968 #[test]
970 fn まとめたtestは出現の本体が変わるとhashが変わる() {
971 let hash_of = |first: &str, second: &str| {
972 let source =
973 FakeSource::new(&[REQ1, ("tests/AuthTests.cs", &cs_overloads(first, second))]);
974 let graph = build_graph(&source, &config()).unwrap();
975 test_node(&graph, "tests/AuthTests:AuthTests:T")
976 .hash
977 .clone()
978 };
979 let base = hash_of("one();", "two();");
980 assert_ne!(base, hash_of("one();", "changed();"));
981 assert_ne!(base, hash_of("changed();", "two();"));
982 }
983
984 #[test]
986 fn まとめたtestは出現の順序を入れ替えるとhashが変わる() {
987 let hash_of = |first: &str, second: &str| {
988 let src = format!(
989 "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"
990 );
991 let source = FakeSource::new(&[REQ1, ("tests/AuthTests.cs", &src)]);
992 let graph = build_graph(&source, &config()).unwrap();
993 test_node(&graph, "tests/AuthTests:AuthTests:T")
994 .hash
995 .clone()
996 };
997 assert_ne!(hash_of("int x", "string s"), hash_of("string s", "int x"));
998 }
999
1000 #[test]
1002 fn 異なるreqを指す重複はduplicate_idとなりノードもエッジも作られない() {
1003 let source = FakeSource::new(&[
1004 REQ1,
1005 REQ2,
1006 (
1007 "pkg/a_test.go",
1008 "package pkg\n\n// [FR-001]\nfunc TestX(t *testing.T) {}\n",
1009 ),
1010 (
1011 "pkg/b_test.go",
1012 "package pkg\n\n// [FR-002]\nfunc TestX(t *testing.T) {}\n",
1013 ),
1014 ]);
1015 let graph = build_graph(&source, &config()).unwrap();
1016 let dups: Vec<_> = graph
1017 .invalid
1018 .iter()
1019 .filter(|i| i.reason == ReasonCode::DuplicateId)
1020 .collect();
1021 assert_eq!(dups.len(), 1, "{:?}", graph.invalid);
1022 assert_eq!(dups[0].target_kind, TargetKind::Test);
1023 assert_eq!(dups[0].target, "pkg:TestX");
1024 assert!(dups[0].detail.contains("pkg/a_test.go"));
1025 assert!(dups[0].detail.contains("pkg/b_test.go"));
1026 assert!(dups[0].detail.contains("FR-001"));
1027 assert!(dups[0].detail.contains("FR-002"));
1028 assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Test));
1029 assert!(graph
1030 .edges
1031 .values()
1032 .all(|e| e.kind != crate::model::EdgeKind::TestReq));
1033 let findings = crate::check::check(&graph, &crate::lock::Lock::default());
1034 for req in ["FR-001", "FR-002"] {
1035 assert!(
1036 findings
1037 .iter()
1038 .any(|f| f.kind == crate::check::FindingKind::NoTest && f.target == req),
1039 "{req}: {findings:?}"
1040 );
1041 }
1042 }
1043
1044 #[test]
1046 fn 同一ファイル内で異なるreqを指す重複は識別子ごとに1件でファイル名は1回だけ出る() {
1047 let source = FakeSource::new(&[
1048 REQ1,
1049 REQ2,
1050 (
1051 "tests/AuthTests.cs",
1052 "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",
1053 ),
1054 ]);
1055 let graph = build_graph(&source, &config()).unwrap();
1056 let dups: Vec<_> = graph
1057 .invalid
1058 .iter()
1059 .filter(|i| i.reason == ReasonCode::DuplicateId)
1060 .collect();
1061 assert_eq!(dups.len(), 1, "{:?}", graph.invalid);
1062 assert_eq!(dups[0].detail.matches("tests/AuthTests.cs").count(), 1);
1063 assert!(dups[0].detail.contains("FR-001"));
1064 assert!(dups[0].detail.contains("FR-002"));
1065 assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Test));
1066 }
1067
1068 #[test]
1070 fn 重複の1つが複数マーカーならその出現はmultiple_markersで残りだけがまとめられる() {
1071 let source = FakeSource::new(&[
1072 REQ1,
1073 REQ2,
1074 (
1075 "tests/AuthTests.cs",
1076 "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",
1077 ),
1078 ]);
1079 let graph = build_graph(&source, &config()).unwrap();
1080 assert!(
1081 graph
1082 .invalid
1083 .iter()
1084 .any(|i| i.reason == ReasonCode::MultipleMarkers),
1085 "{:?}",
1086 graph.invalid
1087 );
1088 assert!(!graph
1089 .invalid
1090 .iter()
1091 .any(|i| i.reason == ReasonCode::DuplicateId));
1092 let node = test_node(&graph, "tests/AuthTests:AuthTests:T");
1093 assert!(node.content.contains("single_a();"));
1094 assert!(node.content.contains("single_b();"));
1095 assert!(!node.content.contains("multi();"));
1096 }
1097
1098 #[test]
1100 fn 重複の1つが複数マーカーで残りが1件なら通常のtestとして扱われる() {
1101 let source = FakeSource::new(&[
1102 REQ1,
1103 REQ2,
1104 (
1105 "tests/AuthTests.cs",
1106 "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",
1107 ),
1108 ]);
1109 let graph = build_graph(&source, &config()).unwrap();
1110 assert!(graph
1111 .invalid
1112 .iter()
1113 .any(|i| i.reason == ReasonCode::MultipleMarkers));
1114 assert!(!graph
1115 .invalid
1116 .iter()
1117 .any(|i| i.reason == ReasonCode::DuplicateId));
1118 let node = test_node(&graph, "tests/AuthTests:AuthTests:T");
1119 assert!(node.content.contains("single_a();"));
1120 assert!(!node.content.contains("multi();"));
1121 }
1122
1123 #[test]
1125 fn マーカーのない同名出現はまとめられない() {
1126 let source = FakeSource::new(&[
1127 REQ1,
1128 (
1129 "tests/AuthTests.cs",
1130 "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",
1131 ),
1132 ]);
1133 let graph = build_graph(&source, &config()).unwrap();
1134 assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
1135 let node = test_node(&graph, "tests/AuthTests:AuthTests:T");
1136 assert!(node.content.contains("marked_body();"));
1137 assert!(!node.content.contains("unmarked_body();"));
1138 }
1139
1140 #[test]
1142 fn 構文エラーのcsharpテストファイルはparse_errorとしてinvalidになる() {
1143 let source = FakeSource::new(&[(
1144 "tests/Broken.cs",
1145 "public class A {\n [Fact]\n public void T( {\n}\n",
1146 )]);
1147 let graph = build_graph(&source, &config()).unwrap();
1148 assert!(graph
1149 .invalid
1150 .iter()
1151 .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("Broken.cs")));
1152 }
1153
1154 #[test]
1156 fn javaファイルからtestノードとエッジが作られる() {
1157 let source = FakeSource::new(&[
1158 REQ1,
1159 (
1160 "tests/AuthTests.java",
1161 "public class AuthTests {\n // [FR-001]\n @Test\n void t() { }\n}\n",
1162 ),
1163 ]);
1164 let graph = build_graph(&source, &config()).unwrap();
1165 assert!(has_test_node(&graph, "tests/AuthTests:AuthTests:t"));
1166 assert!(graph
1167 .edges
1168 .contains_key("test-req:tests/AuthTests:AuthTests:t:FR-001"));
1169 }
1170
1171 #[test]
1173 fn kotlinファイルからtestノードとエッジが作られる() {
1174 let source = FakeSource::new(&[
1175 REQ1,
1176 (
1177 "tests/AuthTest.kt",
1178 "class AuthTest {\n // [FR-001]\n @Test\n fun `一覧 取得`() { }\n}\n",
1179 ),
1180 ]);
1181 let graph = build_graph(&source, &config()).unwrap();
1182 assert!(has_test_node(&graph, "tests/AuthTest:AuthTest:一覧 取得"));
1183 assert!(graph
1184 .edges
1185 .contains_key("test-req:tests/AuthTest:AuthTest:一覧 取得:FR-001"));
1186 }
1187
1188 #[test]
1190 fn kotlinスクリプトのktsファイルは対象外になる() {
1191 let source = FakeSource::new(&[
1192 REQ1,
1193 (
1194 "tests/build.kts",
1195 "class AuthTest {\n // [FR-001]\n @Test\n fun t() { }\n}\n",
1196 ),
1197 ]);
1198 let graph = build_graph(&source, &config()).unwrap();
1199 assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Test));
1200 assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
1201 }
1202
1203 #[test]
1205 fn javaのオーバーロードは1つのtestにまとめられる() {
1206 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";
1207 let source = FakeSource::new(&[REQ1, ("tests/AuthTests.java", src)]);
1208 let graph = build_graph(&source, &config()).unwrap();
1209 assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
1210 let tests: Vec<_> = graph
1211 .nodes
1212 .keys()
1213 .filter(|k| k.kind == NodeKind::Test)
1214 .collect();
1215 assert_eq!(tests.len(), 1, "{tests:?}");
1216 let edges: Vec<_> = graph
1217 .edges
1218 .values()
1219 .filter(|e| e.kind == crate::model::EdgeKind::TestReq)
1220 .collect();
1221 assert_eq!(edges.len(), 1, "{edges:?}");
1222 let node = test_node(&graph, "tests/AuthTests:AuthTests:t");
1223 assert!(node.content.contains("one();"));
1224 assert!(node.content.contains("two();"));
1225 }
1226
1227 #[test]
1229 fn 構文エラーのkotlinテストファイルはparse_errorとしてinvalidになる() {
1230 let source = FakeSource::new(&[("tests/Broken.kt", "class A { fun x() {} }\n")]);
1231 let graph = build_graph(&source, &config()).unwrap();
1232 assert!(graph
1233 .invalid
1234 .iter()
1235 .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("Broken.kt")));
1236 }
1237
1238 #[test]
1240 fn 構文エラーのjavaテストファイルはparse_errorとしてinvalidになる() {
1241 let source = FakeSource::new(&[(
1242 "tests/Broken.java",
1243 "public class A {\n @Test\n void t( {\n}\n",
1244 )]);
1245 let graph = build_graph(&source, &config()).unwrap();
1246 assert!(graph
1247 .invalid
1248 .iter()
1249 .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("Broken.java")));
1250 }
1251
1252 #[test]
1254 fn 構文エラーのtypescriptテストファイルはparse_errorとしてinvalidになる() {
1255 let source = FakeSource::new(&[("src/broken.test.ts", "it(\"a\", () => {\n")]);
1256 let graph = build_graph(&source, &config()).unwrap();
1257 assert!(graph
1258 .invalid
1259 .iter()
1260 .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("broken.test.ts")));
1261 }
1262
1263 #[test]
1265 fn 構文エラーのdartテストファイルはparse_errorとしてinvalidになる() {
1266 let source = FakeSource::new(&[(
1267 "src/broken_test.dart",
1268 "void main() {
1269 test('a', () {
1270",
1271 )]);
1272 let graph = build_graph(&source, &config()).unwrap();
1273 assert!(graph
1274 .invalid
1275 .iter()
1276 .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("broken_test.dart")));
1277 }
1278
1279 #[test]
1281 fn dart_1つのテストに2件以上のマーカーがあるとinvalidになりエッジを作らない() {
1282 let source = FakeSource::new(&[
1283 REQ1,
1284 (
1285 "tests/a_test.dart",
1286 "void main() {\n // [FR-001]\n // [FR-002]\n test('t', () {});\n}\n",
1287 ),
1288 ]);
1289 let graph = build_graph(&source, &config()).unwrap();
1290 assert!(graph
1291 .invalid
1292 .iter()
1293 .any(|i| i.reason == ReasonCode::MultipleMarkers));
1294 assert!(!graph.edges.keys().any(|k| k.starts_with("test-req:")));
1295 }
1296
1297 #[test]
1299 fn csharp_1つのテストに2件以上のマーカーがあるとinvalidになる() {
1300 let source = FakeSource::new(&[(
1301 "tests/AuthTests.cs",
1302 "public class AuthTests\n{\n // [FR-001]\n // [FR-002]\n [Fact]\n public void T() { }\n}\n",
1303 )]);
1304 let graph = build_graph(&source, &config()).unwrap();
1305 assert!(graph
1306 .invalid
1307 .iter()
1308 .any(|i| i.reason == ReasonCode::MultipleMarkers));
1309 }
1310
1311 #[test]
1313 fn typescript_1つのテストに2件以上のマーカーがあるとinvalidになる() {
1314 let source = FakeSource::new(&[(
1315 "src/a.test.ts",
1316 "// [FR-001]\n// [FR-002]\nit(\"a\", () => {});\n",
1317 )]);
1318 let graph = build_graph(&source, &config()).unwrap();
1319 assert!(graph
1320 .invalid
1321 .iter()
1322 .any(|i| i.reason == ReasonCode::MultipleMarkers));
1323 }
1324
1325 #[test]
1327 fn testsのディレクトリ指定はcsharpとtypescriptのファイルにも適用される() {
1328 let source = FakeSource::new(&[
1329 REQ1,
1330 (
1331 "other/AuthTests.cs",
1332 "public class AuthTests\n{\n // [FR-001]\n [Fact]\n public void T() { }\n}\n",
1333 ),
1334 ("other/a.test.ts", "// [FR-001]\nit(\"a\", () => {});\n"),
1335 ]);
1336 let mut cfg = config();
1337 cfg.tests = vec!["tests".to_string()];
1338 let graph = build_graph(&source, &cfg).unwrap();
1339 assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Test));
1340 }
1341
1342 fn exclude_config(names: &[&str]) -> Config {
1343 let mut cfg = config();
1344 cfg.exclude = names.iter().map(|s| s.to_string()).collect();
1345 cfg
1346 }
1347
1348 const MARKED_JS: &str = "// [FR-001]\nit(\"works\", () => {});\n";
1349
1350 #[test]
1352 fn excludeに指定した名前のディレクトリ配下のtestはノードにならない() {
1353 let source = FakeSource::new(&[
1354 REQ1,
1355 ("build/a.test.js", MARKED_JS),
1356 ("src/a.test.js", MARKED_JS),
1357 ]);
1358 let graph = build_graph(&source, &exclude_config(&["build"])).unwrap();
1359 assert!(!has_test_node(&graph, "build/a.test::works"));
1360 assert!(has_test_node(&graph, "src/a.test::works"));
1361 }
1362
1363 #[test]
1365 fn excludeが空なら全ディレクトリのtestがノードになる() {
1366 let source = FakeSource::new(&[
1367 REQ1,
1368 ("build/a.test.js", MARKED_JS),
1369 ("src/a.test.js", MARKED_JS),
1370 ]);
1371 let graph = build_graph(&source, &exclude_config(&[])).unwrap();
1372 assert!(has_test_node(&graph, "build/a.test::works"));
1373 assert!(has_test_node(&graph, "src/a.test::works"));
1374 }
1375
1376 #[test]
1378 fn excludeはネストした深さのディレクトリにも適用される() {
1379 let source = FakeSource::new(&[
1380 REQ1,
1381 ("pkg/build/b.test.js", MARKED_JS),
1382 ("pkg/src/b.test.js", MARKED_JS),
1383 ]);
1384 let graph = build_graph(&source, &exclude_config(&["build"])).unwrap();
1385 assert!(!has_test_node(&graph, "pkg/build/b.test::works"));
1386 assert!(has_test_node(&graph, "pkg/src/b.test::works"));
1387 }
1388
1389 #[test]
1391 fn excludeに既定の除外名と重複する名前を指定しても問題ない() {
1392 let source = FakeSource::new(&[
1393 REQ1,
1394 ("dist/a.test.js", MARKED_JS),
1395 ("src/a.test.js", MARKED_JS),
1396 ]);
1397 let graph = build_graph(&source, &exclude_config(&["dist"])).unwrap();
1398 assert!(!has_test_node(&graph, "dist/a.test::works"));
1399 assert!(has_test_node(&graph, "src/a.test::works"));
1400 }
1401
1402 #[test]
1404 fn excludeは大文字小文字を区別する() {
1405 let source = FakeSource::new(&[REQ1, ("build/a.test.js", MARKED_JS)]);
1406 let graph = build_graph(&source, &exclude_config(&["Build"])).unwrap();
1407 assert!(has_test_node(&graph, "build/a.test::works"));
1408 }
1409
1410 #[test]
1412 fn exclude_nextのようなドット始まりの名前も除外できる() {
1413 let source = FakeSource::new(&[
1414 REQ1,
1415 (".next/a.test.js", MARKED_JS),
1416 ("src/a.test.js", MARKED_JS),
1417 ]);
1418 let graph = build_graph(&source, &exclude_config(&[".next"])).unwrap();
1419 assert!(!has_test_node(&graph, ".next/a.test::works"));
1420 assert!(has_test_node(&graph, "src/a.test::works"));
1421 }
1422
1423 #[test]
1425 fn 予約名のindex_mdとlog_mdはdocsでもrequirementsでもノードにならない() {
1426 let source = FakeSource::new(&[
1427 (
1428 "docs/index.md",
1429 "---\ntype: BusinessNeed\nid: DOC-X\n---\n[r](../req/fr1.md)\n",
1430 ),
1431 ("docs/log.md", "---\ntype: BusinessNeed\nid: DOC-L\n---\n"),
1432 ("docs/sub/index.md", "---\ntype: BusinessNeed\n---\n"),
1433 ("req/index.md", "---\ntype: Requirement\nid: FR-001\n---\n"),
1434 ("req/log.md", "---\ntype: Requirement\n---\n"),
1435 (
1436 "req/fr1.md",
1437 "---\ntype: Requirement\nid: FR-001\n---\n本文\n",
1438 ),
1439 ]);
1440 let graph = build_graph(&source, &config()).unwrap();
1441 assert!(
1442 graph.invalid.is_empty(),
1443 "reserved names must not be invalid: {:?}",
1444 graph.invalid
1445 );
1446 assert!(!graph.nodes.values().any(|n| n.key.kind == NodeKind::Doc));
1447 let reqs: Vec<&str> = graph
1448 .nodes
1449 .values()
1450 .filter(|n| n.key.kind == NodeKind::Req)
1451 .map(|n| n.path.as_str())
1452 .collect();
1453 assert_eq!(reqs, ["req/fr1.md"]);
1454 assert!(graph.edges.is_empty(), "{:?}", graph.edges);
1455 }
1456
1457 #[test]
1459 fn index未設定なら索引のfindingを一切出さない() {
1460 let source = FakeSource::new(&[
1461 ("docs/a.md", "---\ntype: BusinessNeed\nid: DOC-A\n---\n"),
1462 ("docs/untyped.md", "plain\n"),
1463 ]);
1464 let graph = build_graph(&source, &config()).unwrap();
1465 assert!(graph.index_issues.is_empty(), "{:?}", graph.index_issues);
1466 }
1467
1468 #[test]
1470 fn index設定があっても索引のtype_business_needはノードにならない() {
1471 let source = FakeSource::new(&[
1472 (
1473 "docs/index.md",
1474 "---\ntype: BusinessNeed\nid: DOC-X\n---\n- [A](a.md) — D\n",
1475 ),
1476 (
1477 "docs/a.md",
1478 "---\ntype: BusinessNeed\nid: DOC-A\ntitle: A\ndescription: D\n---\n",
1479 ),
1480 ]);
1481 let mut cfg = config();
1482 cfg.index = vec!["docs".to_string()];
1483 let graph = build_graph(&source, &cfg).unwrap();
1484 assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
1485 let docs: Vec<&str> = graph
1486 .nodes
1487 .values()
1488 .filter(|n| n.key.kind == NodeKind::Doc)
1489 .map(|n| n.path.as_str())
1490 .collect();
1491 assert_eq!(docs, ["docs/a.md"]);
1492 assert!(graph.index_issues.is_empty(), "{:?}", graph.index_issues);
1493 }
1494
1495 #[test]
1497 fn index設定があればbuild_graphが索引のfindingを集める() {
1498 let source = FakeSource::new(&[
1499 (
1500 "docs/a.md",
1501 "---\ntype: BusinessNeed\nid: DOC-A\ntitle: A\ndescription: D\n---\n",
1502 ),
1503 ("docs/untyped.md", "plain\n"),
1504 ]);
1505 let mut cfg = config();
1506 cfg.index = vec!["docs".to_string()];
1507 let graph = build_graph(&source, &cfg).unwrap();
1508 assert!(graph
1509 .index_issues
1510 .iter()
1511 .any(|i| i.reason == ReasonCode::MissingIndex && i.target == "docs"));
1512 assert!(graph
1513 .index_issues
1514 .iter()
1515 .any(|i| i.reason == ReasonCode::UntypedFile && i.target == "docs/untyped.md"));
1516 }
1517
1518 fn indexed_config(roots: &[&str]) -> Config {
1519 let mut cfg = config();
1520 cfg.index = roots.iter().map(|r| r.to_string()).collect();
1521 cfg
1522 }
1523
1524 fn desc_edges(graph: &crate::model::Graph) -> Vec<&crate::model::Edge> {
1525 graph
1526 .edges
1527 .values()
1528 .filter(|e| e.kind == crate::model::EdgeKind::Desc)
1529 .collect()
1530 }
1531
1532 const DOC_A: &str = "---\ntype: BusinessNeed\nid: DOC-A\ndescription: 要約A\n---\n本文A\n";
1533 const REQ_1: &str = "---\ntype: Requirement\nid: FR-001\ndescription: 要約1\n---\n本文1\n";
1534
1535 #[test]
1537 fn index配下のdocとreqにdescエッジが1本ずつできる() {
1538 let source = FakeSource::new(&[("docs/a.md", DOC_A), ("docs/r.md", REQ_1)]);
1539 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1540 let doc = graph.edges.get("desc:doc:DOC-A").expect("desc:doc:DOC-A");
1541 assert_eq!(doc.kind, crate::model::EdgeKind::Desc);
1542 assert_eq!(doc.from, doc.to);
1543 assert_eq!(doc.from.kind, NodeKind::Doc);
1544 assert_eq!(doc.from.id, "DOC-A");
1545 let req = graph.edges.get("desc:req:FR-001").expect("desc:req:FR-001");
1546 assert_eq!(req.kind, crate::model::EdgeKind::Desc);
1547 assert_eq!(req.from, req.to);
1548 assert_eq!(req.from.kind, NodeKind::Req);
1549 assert_eq!(desc_edges(&graph).len(), 2, "{:?}", graph.edges);
1550 }
1551
1552 #[test]
1554 fn descエッジはdescriptionが無いノードにもできる() {
1555 let source = FakeSource::new(&[(
1556 "docs/a.md",
1557 "---\ntype: BusinessNeed\nid: DOC-A\n---\n本文A\n",
1558 )]);
1559 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1560 assert!(
1561 graph.edges.contains_key("desc:doc:DOC-A"),
1562 "{:?}",
1563 graph.edges
1564 );
1565 }
1566
1567 #[test]
1569 fn descエッジは空や改行を含むdescriptionのノードにもできる() {
1570 let source = FakeSource::new(&[
1571 (
1572 "docs/a.md",
1573 "---\ntype: BusinessNeed\nid: DOC-A\ndescription: \"\"\n---\n本文\n",
1574 ),
1575 (
1576 "docs/b.md",
1577 "---\ntype: BusinessNeed\nid: DOC-B\ndescription: |\n 一行目\n 二行目\n---\n本文\n",
1578 ),
1579 ]);
1580 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1581 assert!(
1582 graph.edges.contains_key("desc:doc:DOC-A"),
1583 "{:?}",
1584 graph.edges
1585 );
1586 assert!(
1587 graph.edges.contains_key("desc:doc:DOC-B"),
1588 "{:?}",
1589 graph.edges
1590 );
1591 }
1592
1593 #[test]
1595 fn indexが空ならdescエッジは1本もできない() {
1596 let source = FakeSource::new(&[("docs/a.md", DOC_A), ("docs/r.md", REQ_1)]);
1597 let graph = build_graph(&source, &config()).unwrap();
1598 assert!(desc_edges(&graph).is_empty(), "{:?}", graph.edges);
1599 }
1600
1601 #[test]
1603 fn 索引の根の外にあるreqにはdescエッジができない() {
1604 let source = FakeSource::new(&[("docs/a.md", DOC_A), ("req/fr1.md", REQ_1)]);
1605 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1606 assert!(graph.edges.contains_key("desc:doc:DOC-A"));
1607 assert!(
1608 !graph.edges.contains_key("desc:req:FR-001"),
1609 "{:?}",
1610 graph.edges
1611 );
1612 }
1613
1614 #[test]
1616 fn 索引の根の配下にあるreqにはdescエッジができる() {
1617 let source = FakeSource::new(&[("docs/a.md", DOC_A), ("req/fr1.md", REQ_1)]);
1618 let graph = build_graph(&source, &indexed_config(&["docs", "req"])).unwrap();
1619 assert!(
1620 graph.edges.contains_key("desc:req:FR-001"),
1621 "{:?}",
1622 graph.edges
1623 );
1624 }
1625
1626 #[test]
1628 fn 無効なidのノードにはdescエッジができない() {
1629 let source = FakeSource::new(&[
1630 (
1631 "docs/bad.md",
1632 "---\ntype: BusinessNeed\nid: BAD ID\ndescription: D\n---\n本文\n",
1633 ),
1634 (
1635 "docs/noid.md",
1636 "---\ntype: BusinessNeed\ndescription: D\n---\n本文\n",
1637 ),
1638 ]);
1639 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1640 assert!(!graph.invalid.is_empty());
1641 assert!(desc_edges(&graph).is_empty(), "{:?}", graph.edges);
1642 }
1643
1644 #[test]
1646 fn guideなどmiwaのノードでない文書にはdescエッジができない() {
1647 let source = FakeSource::new(&[(
1648 "docs/g.md",
1649 "---\ntype: Guide\nid: GUIDE-1\ndescription: D\n---\n本文\n",
1650 )]);
1651 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1652 assert!(desc_edges(&graph).is_empty(), "{:?}", graph.edges);
1653 }
1654
1655 #[test]
1657 fn descエッジはorphanとno_testの判定に影響しない() {
1658 let source = FakeSource::new(&[("docs/r.md", REQ_1), ("docs/a.md", DOC_A)]);
1659 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1660 let findings = crate::check::check(&graph, &crate::lock::Lock::default());
1661 assert!(findings
1662 .iter()
1663 .any(|f| f.kind == crate::check::FindingKind::Orphan
1664 && f.reason == ReasonCode::ReqWithoutDoc
1665 && f.target == "FR-001"));
1666 assert!(findings
1667 .iter()
1668 .any(|f| f.kind == crate::check::FindingKind::NoTest && f.target == "FR-001"));
1669 assert!(findings
1670 .iter()
1671 .any(|f| f.kind == crate::check::FindingKind::Orphan
1672 && f.reason == ReasonCode::DocWithoutReq
1673 && f.target == "DOC-A"));
1674 }
1675
1676 #[test]
1678 fn ロックにdescエッジがありノードが無ければremovedになる() {
1679 let source = FakeSource::new(&[("docs/a.md", DOC_A)]);
1680 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1681 let mut lock = crate::lock::Lock::default();
1682 lock.edges.insert(
1683 "desc:doc:DOC-GONE".to_string(),
1684 crate::lock::LockEntry {
1685 from: "sha256:x".to_string(),
1686 to: "sha256:y".to_string(),
1687 by: "human".to_string(),
1688 reason: None,
1689 score: None,
1690 at: "2026-01-01T00:00:00Z".to_string(),
1691 },
1692 );
1693 let findings = crate::check::check(&graph, &lock);
1694 assert!(findings
1695 .iter()
1696 .any(|f| f.kind == crate::check::FindingKind::Removed
1697 && f.target == "desc:doc:DOC-GONE"
1698 && f.reason == ReasonCode::RemovedEdge));
1699 }
1700
1701 #[test]
1703 fn indexの根から外れるとロックのdescエッジはremovedになる() {
1704 let source = FakeSource::new(&[("docs/a.md", DOC_A)]);
1705 let with_index = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1706 let mut lock = crate::lock::Lock::default();
1707 let (from, to) =
1708 crate::model::edge_hashes(&with_index, &with_index.edges["desc:doc:DOC-A"]);
1709 lock.edges.insert(
1710 "desc:doc:DOC-A".to_string(),
1711 crate::lock::LockEntry {
1712 from,
1713 to,
1714 by: "human".to_string(),
1715 reason: None,
1716 score: None,
1717 at: "2026-01-01T00:00:00Z".to_string(),
1718 },
1719 );
1720 let without_index = build_graph(&source, &config()).unwrap();
1721 let findings = crate::check::check(&without_index, &lock);
1722 assert!(findings
1723 .iter()
1724 .any(|f| f.kind == crate::check::FindingKind::Removed && f.target == "desc:doc:DOC-A"));
1725 }
1726
1727 fn desc_hashes(graph: &crate::model::Graph, id: &str) -> (String, String) {
1728 crate::model::edge_hashes(graph, &graph.edges[id])
1729 }
1730
1731 #[test]
1733 fn descエッジのfromは要約_toは本文のハッシュになる() {
1734 let source = FakeSource::new(&[("docs/a.md", DOC_A)]);
1735 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1736 assert_eq!(
1737 desc_hashes(&graph, "desc:doc:DOC-A"),
1738 (
1739 crate::hash::content_hash("要約A"),
1740 crate::hash::content_hash("本文A\n")
1741 )
1742 );
1743 }
1744
1745 #[test]
1747 fn descエッジのfromは前後の空白を除いた要約のハッシュになる() {
1748 let source = FakeSource::new(&[(
1749 "docs/a.md",
1750 "---\ntype: BusinessNeed\nid: DOC-A\ndescription: \" 要約A \"\n---\n本文A\n",
1751 )]);
1752 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1753 assert_eq!(
1754 desc_hashes(&graph, "desc:doc:DOC-A").0,
1755 crate::hash::content_hash("要約A")
1756 );
1757 }
1758
1759 #[test]
1761 fn descエッジのfromは要約が無い_空_改行を含むときは空文字列のハッシュになる() {
1762 let empty = crate::hash::content_hash("");
1763 for fm in [
1764 "type: BusinessNeed\nid: DOC-A",
1765 "type: BusinessNeed\nid: DOC-A\ndescription: \"\"",
1766 "type: BusinessNeed\nid: DOC-A\ndescription: |\n 一行目\n 二行目",
1767 ] {
1768 let source = FakeSource::new(&[("docs/a.md", &format!("---\n{fm}\n---\n本文\n"))]);
1769 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1770 assert_eq!(desc_hashes(&graph, "desc:doc:DOC-A").0, empty, "{fm}");
1771 }
1772 }
1773
1774 #[test]
1776 fn descエッジのfromは折り畳みスカラーの要約を1行に畳んだハッシュになる() {
1777 let source = FakeSource::new(&[(
1778 "docs/a.md",
1779 "---\ntype: BusinessNeed\nid: DOC-A\ndescription: >\n 一行目\n 二行目\n---\n本文\n",
1780 )]);
1781 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1782 assert_eq!(
1783 desc_hashes(&graph, "desc:doc:DOC-A").0,
1784 crate::hash::content_hash("一行目 二行目")
1785 );
1786 }
1787
1788 #[test]
1790 fn 同じidのdocとreqは別々のdescエッジになる() {
1791 let source = FakeSource::new(&[
1792 (
1793 "docs/a.md",
1794 "---\ntype: BusinessNeed\nid: X-1\ndescription: 要約D\n---\n本文D\n",
1795 ),
1796 (
1797 "docs/r.md",
1798 "---\ntype: Requirement\nid: X-1\ndescription: 要約R\n---\n本文R\n",
1799 ),
1800 ]);
1801 let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1802 assert!(
1803 graph.edges.contains_key("desc:doc:X-1"),
1804 "{:?}",
1805 graph.edges
1806 );
1807 assert!(
1808 graph.edges.contains_key("desc:req:X-1"),
1809 "{:?}",
1810 graph.edges
1811 );
1812 assert_eq!(desc_edges(&graph).len(), 2, "{:?}", graph.edges);
1813 assert_eq!(
1814 desc_hashes(&graph, "desc:doc:X-1").0,
1815 crate::hash::content_hash("要約D")
1816 );
1817 assert_eq!(
1818 desc_hashes(&graph, "desc:req:X-1").0,
1819 crate::hash::content_hash("要約R")
1820 );
1821 }
1822
1823 #[test]
1825 fn 要約だけを変えてもdoc_reqとtest_reqのハッシュは変わらずfindingも出ない() {
1826 let build = |description: &str| {
1827 let source = FakeSource::new(&[
1828 (
1829 "docs/a.md",
1830 &format!(
1831 "---\ntype: BusinessNeed\nid: DOC-A\ndescription: {description}\n---\n[r](../req/fr1.md)\n"
1832 ),
1833 ),
1834 (
1835 "req/fr1.md",
1836 "---\ntype: Requirement\nid: FR-001\n---\n本文1\n",
1837 ),
1838 ("tests/t.rs", "// [FR-001]\n#[test]\nfn t() {}\n"),
1839 ]);
1840 build_graph(&source, &indexed_config(&["docs"])).unwrap()
1841 };
1842 let old = build("旧要約");
1843 let new = build("新要約");
1844 let test_edge = old
1845 .edges
1846 .keys()
1847 .find(|k| k.starts_with("test-req:"))
1848 .cloned()
1849 .expect("test-req edge");
1850 for id in ["doc-req:DOC-A:FR-001", test_edge.as_str()] {
1851 assert_eq!(desc_hashes(&old, id), desc_hashes(&new, id), "{id}");
1852 }
1853 assert_ne!(
1854 desc_hashes(&old, "desc:doc:DOC-A").0,
1855 desc_hashes(&new, "desc:doc:DOC-A").0
1856 );
1857 let mut lock = crate::lock::Lock::default();
1858 for (id, edge) in &old.edges {
1859 let (from, to) = crate::model::edge_hashes(&old, edge);
1860 lock.edges.insert(
1861 id.clone(),
1862 crate::lock::LockEntry {
1863 from,
1864 to,
1865 by: "human".to_string(),
1866 reason: None,
1867 score: None,
1868 at: "2026-01-01T00:00:00Z".to_string(),
1869 },
1870 );
1871 }
1872 let drifts: Vec<String> = crate::check::check(&new, &lock)
1873 .into_iter()
1874 .filter(|f| f.kind == crate::check::FindingKind::Drift)
1875 .map(|f| f.target)
1876 .collect();
1877 assert_eq!(drifts, vec!["desc:doc:DOC-A".to_string()]);
1878 }
1879}