Files
storybook/src/lsp/tests.rs
Sienna Meridian Satterwhite 25d59d6107 feat(type-system): implement concept_comparison with pattern matching
Added complete support for the new type system syntax including:

- concept: Base type declarations
- sub_concept: Enum and record sub-type definitions
- concept_comparison: Compile-time pattern matching with conditional guards

Parser changes:
- Added VariantPattern, FieldCondition, and Condition AST nodes
- Implemented "is" keyword for pattern matching (e.g., "CupType is Glass or CupType is Plastic")
- Added Value::Any variant to support universal type matching
- Disambiguated enum-like vs record-like sub_concept syntax

LSP updates:
- Added Value::Any match arms across code_actions, completion, hover, inlay_hints, and semantic_tokens
- Type inference and formatting support for Any values

Example fixes:
- Fixed syntax error in baker-family behaviors (missing closing brace in nested if)
- Removed deprecated core_enums.sb file
2026-02-14 09:28:20 +00:00

612 lines
19 KiB
Rust

//! Comprehensive test suite for LSP server functionality
use document::Document;
use tower_lsp::lsp_types::*;
use super::*;
// Test data fixtures
const SAMPLE_STORYBOOK: &str = r#"
species Human {
intelligence: high
lifespan: 80
}
character Alice: Human {
age: 7
---backstory
A curious girl who loves adventures
---
}
template Child {
age: 5..12
guardian: Human
}
character Bob: Human from Child {
age: 10
guardian: Alice
}
life_arc Growing {
state child {
on enter {
age: 5
}
}
state teen {
on enter {
age: 13
}
}
state adult {
on enter {
age: 18
}
}
}
schedule DailyRoutine {
08:00 -> 09:00: breakfast {
activity: eating
}
09:00 -> 12:00: school {
activity: learning
}
}
relationship Friendship {
Alice as friend {
bond_strength: 5
}
Bob as friend {
bond_strength: 5
}
}
"#;
#[cfg(test)]
mod document_tests {
use super::*;
#[test]
fn test_document_creation() {
let doc = Document::new(SAMPLE_STORYBOOK.to_string());
if !doc.parse_errors.is_empty() {
eprintln!("Parse errors:");
for err in &doc.parse_errors {
eprintln!(" - {}", err.message);
}
}
assert_eq!(doc.text, SAMPLE_STORYBOOK);
assert!(doc.ast.is_some(), "AST should be parsed");
}
#[test]
fn test_document_with_errors() {
let invalid = "character { invalid syntax }";
let doc = Document::new(invalid.to_string());
assert!(doc.ast.is_none(), "Invalid syntax should not produce AST");
assert!(!doc.parse_errors.is_empty(), "Should have parse errors");
}
#[test]
fn test_document_update() {
let mut doc = Document::new("character Alice {}".to_string());
doc.update("character Bob {}".to_string());
assert_eq!(doc.text, "character Bob {}");
assert!(doc.name_table.resolve_name("Bob").is_some());
assert!(doc.name_table.resolve_name("Alice").is_none());
}
#[test]
fn test_symbol_extraction() {
let doc = Document::new(SAMPLE_STORYBOOK.to_string());
assert!(doc.name_table.resolve_name("Alice").is_some());
assert!(doc.name_table.resolve_name("Bob").is_some());
assert!(doc.name_table.resolve_name("Child").is_some());
assert!(doc.name_table.resolve_name("Growing").is_some());
assert!(doc.name_table.resolve_name("DailyRoutine").is_some());
assert!(doc.name_table.resolve_name("Human").is_some());
assert!(doc.name_table.resolve_name("Friendship").is_some());
}
#[test]
fn test_symbol_kinds() {
use crate::resolve::names::DeclKind;
let doc = Document::new(SAMPLE_STORYBOOK.to_string());
let alice = doc.name_table.resolve_name("Alice").unwrap();
assert_eq!(alice.kind, DeclKind::Character);
let child = doc.name_table.resolve_name("Child").unwrap();
assert_eq!(child.kind, DeclKind::Template);
let growing = doc.name_table.resolve_name("Growing").unwrap();
assert_eq!(growing.kind, DeclKind::LifeArc);
}
#[test]
fn test_word_at_offset() {
let doc = Document::new("character Alice {}".to_string());
// Test finding "character" keyword
let word = doc.word_at_offset(5);
assert_eq!(word, Some("character".to_string()));
// Test finding "Alice" identifier
let word = doc.word_at_offset(12);
assert_eq!(word, Some("Alice".to_string()));
// Test whitespace returns None
let word = doc.word_at_offset(9);
assert_eq!(word, None);
}
}
#[cfg(test)]
mod position_tests {
use crate::position::PositionTracker;
#[test]
fn test_position_tracking_single_line() {
let mut tracker = PositionTracker::new("hello world");
assert_eq!(tracker.offset_to_position(0), (0, 0));
assert_eq!(tracker.offset_to_position(6), (0, 6));
assert_eq!(tracker.offset_to_position(11), (0, 11));
}
#[test]
fn test_position_tracking_multiline() {
let mut tracker = PositionTracker::new("line 1\nline 2\nline 3");
// Start of first line
assert_eq!(tracker.offset_to_position(0), (0, 0));
// Start of second line (after \n at offset 6)
assert_eq!(tracker.offset_to_position(7), (1, 0));
// Start of third line (after \n at offset 13)
assert_eq!(tracker.offset_to_position(14), (2, 0));
// Middle of second line
assert_eq!(tracker.offset_to_position(10), (1, 3));
}
#[test]
fn test_line_count() {
let tracker = PositionTracker::new("line 1\nline 2\nline 3");
assert_eq!(tracker.line_count(), 3);
}
#[test]
fn test_line_offset() {
let tracker = PositionTracker::new("line 1\nline 2\nline 3");
assert_eq!(tracker.line_offset(0), Some(0));
assert_eq!(tracker.line_offset(1), Some(7));
assert_eq!(tracker.line_offset(2), Some(14));
assert_eq!(tracker.line_offset(3), None);
}
}
#[cfg(test)]
mod hover_tests {
use super::*;
#[test]
fn test_hover_keywords() {
// Test character keyword
let hover = hover::get_hover_info("character Alice {}", 0, 5);
assert!(hover.is_some());
let hover = hover.unwrap();
if let HoverContents::Markup(content) = hover.contents {
assert!(content.value.contains("character"));
assert!(content.value.contains("Defines a character entity"));
}
// Test template keyword
let hover = hover::get_hover_info("template Child {}", 0, 2);
assert!(hover.is_some());
// Test life_arc keyword
let hover = hover::get_hover_info("life_arc Growing {}", 0, 5);
assert!(hover.is_some());
}
#[test]
fn test_hover_non_keyword() {
let hover = hover::get_hover_info("character Alice {}", 0, 12);
assert!(hover.is_none());
}
#[test]
fn test_hover_invalid_position() {
let hover = hover::get_hover_info("character Alice {}", 0, 100);
assert!(hover.is_none());
}
}
#[cfg(test)]
mod completion_tests {
use super::*;
#[test]
fn test_keyword_completions() {
let doc = Document::new(SAMPLE_STORYBOOK.to_string());
let params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: Url::parse("file:///test.sb").unwrap(),
},
position: Position {
line: 0,
character: 0,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = completion::get_completions(&doc, &params);
assert!(result.is_some());
if let Some(CompletionResponse::Array(items)) = result {
// Should have keyword completions
assert!(items.iter().any(|item| item.label == "character"));
assert!(items.iter().any(|item| item.label == "template"));
assert!(items.iter().any(|item| item.label == "life_arc"));
// Should have entity completions from document
assert!(items.iter().any(|item| item.label == "Alice"));
assert!(items.iter().any(|item| item.label == "Bob"));
}
}
#[test]
fn test_completion_includes_snippets() {
let doc = Document::new("".to_string());
let params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: Url::parse("file:///test.sb").unwrap(),
},
position: Position::default(),
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = completion::get_completions(&doc, &params);
if let Some(CompletionResponse::Array(items)) = result {
// Check that character completion has a snippet
let character_item = items.iter().find(|item| item.label == "character");
assert!(character_item.is_some());
let character_item = character_item.unwrap();
assert!(character_item.insert_text.is_some());
assert_eq!(
character_item.insert_text_format,
Some(InsertTextFormat::SNIPPET)
);
}
}
}
#[cfg(test)]
mod formatting_tests {
use super::*;
#[test]
fn test_basic_formatting() {
let doc = Document::new("character Alice{age:7}".to_string());
let options = FormattingOptions {
tab_size: 4,
insert_spaces: true,
..Default::default()
};
let result = formatting::format_document(&doc, &options);
assert!(result.is_some());
let edits = result.unwrap();
assert_eq!(edits.len(), 1);
let formatted = &edits[0].new_text;
eprintln!("Formatted output:\n{}", formatted);
assert!(formatted.contains("character Alice {") || formatted.contains("character Alice{"));
assert!(formatted.contains("age: 7") || formatted.contains("age:7"));
}
#[test]
fn test_formatting_indentation() {
let doc = Document::new("character Alice {\nage: 7\n}".to_string());
let options = FormattingOptions {
tab_size: 4,
insert_spaces: true,
..Default::default()
};
let result = formatting::format_document(&doc, &options);
assert!(result.is_some());
let formatted = &result.unwrap()[0].new_text;
// Check that age is indented with 4 spaces
assert!(formatted.contains(" age: 7"));
}
#[test]
fn test_formatting_preserves_prose() {
let doc = Document::new(
"character Alice {\n---backstory\nSome irregular spacing\n---\n}".to_string(),
);
let options = FormattingOptions::default();
let result = formatting::format_document(&doc, &options);
let formatted = &result.unwrap()[0].new_text;
// Prose content should be preserved exactly
assert!(formatted.contains("Some irregular spacing"));
}
#[test]
fn test_no_formatting_needed() {
let already_formatted = "character Alice {\n age: 7\n}\n";
let doc = Document::new(already_formatted.to_string());
let options = FormattingOptions::default();
let result = formatting::format_document(&doc, &options);
// Should return None if no changes needed
assert!(result.is_none());
}
}
#[cfg(test)]
mod symbols_tests {
use super::*;
#[test]
fn test_extract_symbols_from_ast() {
let doc = Document::new(SAMPLE_STORYBOOK.to_string());
let ast = doc.ast.as_ref().unwrap();
let mut positions = doc.positions.clone();
let symbols = symbols::extract_symbols_from_ast(ast, &mut positions);
// Should have top-level declarations
assert!(symbols.iter().any(|s| s.name == "Alice"));
assert!(symbols.iter().any(|s| s.name == "Child"));
assert!(symbols.iter().any(|s| s.name == "Bob"));
assert!(symbols.iter().any(|s| s.name == "Growing"));
assert!(symbols.iter().any(|s| s.name == "DailyRoutine"));
assert!(symbols.iter().any(|s| s.name == "Human"));
assert!(symbols.iter().any(|s| s.name == "Friendship"));
}
#[test]
fn test_symbol_hierarchy() {
let doc = Document::new(SAMPLE_STORYBOOK.to_string());
let ast = doc.ast.as_ref().unwrap();
let mut positions = doc.positions.clone();
let symbols = symbols::extract_symbols_from_ast(ast, &mut positions);
// Alice should have children (fields)
let alice = symbols.iter().find(|s| s.name == "Alice").unwrap();
assert!(alice.children.is_some());
let children = alice.children.as_ref().unwrap();
assert!(children.iter().any(|c| c.name == "age"));
assert!(children.iter().any(|c| c.name == "backstory"));
}
#[test]
fn test_symbol_kinds() {
let doc = Document::new(SAMPLE_STORYBOOK.to_string());
let ast = doc.ast.as_ref().unwrap();
let mut positions = doc.positions.clone();
let symbols = symbols::extract_symbols_from_ast(ast, &mut positions);
let alice = symbols.iter().find(|s| s.name == "Alice").unwrap();
assert_eq!(alice.kind, SymbolKind::CLASS);
let child = symbols.iter().find(|s| s.name == "Child").unwrap();
assert_eq!(child.kind, SymbolKind::INTERFACE);
}
#[test]
fn test_life_arc_states() {
let doc = Document::new(SAMPLE_STORYBOOK.to_string());
let ast = doc.ast.as_ref().unwrap();
let mut positions = doc.positions.clone();
let symbols = symbols::extract_symbols_from_ast(ast, &mut positions);
let growing = symbols.iter().find(|s| s.name == "Growing").unwrap();
assert!(growing.children.is_some());
let states = growing.children.as_ref().unwrap();
assert!(states.iter().any(|s| s.name == "child"));
assert!(states.iter().any(|s| s.name == "teen"));
assert!(states.iter().any(|s| s.name == "adult"));
}
}
#[cfg(test)]
mod definition_tests {
use super::*;
#[test]
fn test_goto_definition_character() {
let mut doc = Document::new(SAMPLE_STORYBOOK.to_string());
// Find position of "Alice" in "character Alice"
let alice_offset = doc.text.find("character Alice").unwrap() + "character ".len();
let (line, col) = doc.positions.offset_to_position(alice_offset);
let params = GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: Url::parse("file:///test.sb").unwrap(),
},
position: Position {
line: line as u32,
character: col as u32,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let uri = Url::parse("file:///test.sb").unwrap();
let result = definition::get_definition(&doc, &params, &uri);
assert!(result.is_some());
}
#[test]
fn test_goto_definition_not_found() {
let doc = Document::new("character Alice {}".to_string());
let params = GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: Url::parse("file:///test.sb").unwrap(),
},
position: Position {
line: 0,
character: 0, // On "character" keyword, not a symbol
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let uri = Url::parse("file:///test.sb").unwrap();
let result = definition::get_definition(&doc, &params, &uri);
assert!(result.is_none());
}
}
#[cfg(test)]
mod references_tests {
use super::*;
#[test]
fn test_find_references() {
let source = "character Alice {}\ncharacter Bob { friend: Alice }";
let mut doc = Document::new(source.to_string());
// Find position of first "Alice"
let alice_offset = source.find("Alice").unwrap();
let (line, col) = doc.positions.offset_to_position(alice_offset);
let params = ReferenceParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: Url::parse("file:///test.sb").unwrap(),
},
position: Position {
line: line as u32,
character: col as u32,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: ReferenceContext {
include_declaration: true,
},
};
let uri = Url::parse("file:///test.sb").unwrap();
let result = references::find_references(&doc, &params, &uri);
assert!(result.is_some());
let locations = result.unwrap();
// Should find both occurrences of "Alice"
assert_eq!(locations.len(), 2);
}
#[test]
fn test_find_references_word_boundaries() {
let source = "character Alice {}\ncharacter Alicia {}";
let mut doc = Document::new(source.to_string());
let alice_offset = source.find("Alice").unwrap();
let (line, col) = doc.positions.offset_to_position(alice_offset);
let params = ReferenceParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: Url::parse("file:///test.sb").unwrap(),
},
position: Position {
line: line as u32,
character: col as u32,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: ReferenceContext {
include_declaration: true,
},
};
let uri = Url::parse("file:///test.sb").unwrap();
let result = references::find_references(&doc, &params, &uri);
let locations = result.unwrap();
// Should only find "Alice", not "Alicia"
assert_eq!(locations.len(), 1);
}
}
#[cfg(test)]
mod integration_tests {
use super::*;
#[test]
fn test_full_workflow() {
// Create a document
let mut doc = Document::new(SAMPLE_STORYBOOK.to_string());
// Verify parsing worked
assert!(doc.ast.is_some());
assert!(doc.parse_errors.is_empty());
// Verify symbols were extracted
assert!(doc.name_table.all_entries().count() > 0);
// Test updating document
doc.update("character NewChar {}".to_string());
assert!(doc.name_table.resolve_name("NewChar").is_some());
// Verify new AST was parsed
assert!(doc.ast.is_some());
}
#[test]
fn test_error_recovery() {
let invalid = "character { invalid }";
let doc = Document::new(invalid.to_string());
// Should handle errors gracefully
assert!(doc.ast.is_none());
assert!(!doc.parse_errors.is_empty());
// Symbols should be empty for invalid document
assert_eq!(doc.name_table.all_entries().count(), 0);
}
}