use ass_editor::{
commands::*,
core::{EditorDocument, Position, Range, StyleBuilder},
};
use proptest::prelude::*;
fn arb_text() -> impl Strategy<Value = String> {
prop_oneof![
"[a-zA-Z0-9 ]{0,50}",
"\\{\\\\[a-z0-9]+\\}[a-zA-Z ]{0,30}",
"[\u{0020}-\u{005A}\u{005C}-\u{007E}\u{00A0}-\u{00FF}]{0,40}",
Just("".to_string()),
]
}
fn find_char_boundary(text: &str, offset: usize) -> usize {
if offset >= text.len() {
return text.len();
}
let mut pos = offset;
while pos > 0 && !text.is_char_boundary(pos) {
pos -= 1;
}
pos
}
fn arb_ass_script() -> impl Strategy<Value = String> {
(
prop::collection::vec(arb_style(), 1..5),
prop::collection::vec(arb_event(), 0..20),
)
.prop_map(|(styles, events)| {
let mut script = String::from(
"[Script Info]\nTitle: Test\nScriptType: v4.00+\n\n[V4+ Styles]\n"
);
script.push_str("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n");
for style in styles {
script.push_str(&style);
script.push('\n');
}
script.push_str("\n[Events]\n");
script.push_str("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n");
for event in events {
script.push_str(&event);
script.push('\n');
}
script
})
}
fn arb_style() -> impl Strategy<Value = String> {
(
"[A-Za-z][A-Za-z0-9]{0,15}", prop::bool::ANY, prop::bool::ANY, 10..50u32, )
.prop_map(|(name, bold, italic, size)| {
format!(
"Style: {name},Arial,{size},&H00FFFFFF,&H000000FF,&H00000000,&H00000000,{},{},0,0,100,100,0,0,1,2,0,2,10,10,10,1",
bold as i32,
italic as i32
)
})
}
fn arb_event() -> impl Strategy<Value = String> {
(
0..3u32, 0..300u32, 1..10u32, arb_text(), )
.prop_map(|(layer, start, duration, text)| {
let end = start + duration;
format!(
"Dialogue: {layer},0:{:02}:{:02}.00,0:{:02}:{:02}.00,Default,,0,0,0,,{text}",
start / 60,
start % 60,
end / 60,
end % 60
)
})
}
proptest! {
#[test]
fn test_insert_undo_invariant(
script in arb_ass_script(),
text in arb_text(),
) {
let mut doc = EditorDocument::from_content(&script)?;
let original_text = doc.text();
let max_pos = doc.len();
if max_pos > 0 && !text.is_empty() {
let insert_offset = find_char_boundary(&original_text, max_pos / 2);
let pos = Position::new(insert_offset);
doc.insert(pos, &text)?;
prop_assert_ne!(doc.text(), original_text.clone());
doc.undo()?;
prop_assert_eq!(doc.text(), original_text);
doc.redo()?;
prop_assert!(doc.text().contains(&text));
}
}
#[test]
fn test_replace_maintains_validity(
script in arb_ass_script(),
new_text in arb_text(),
) {
let mut doc = EditorDocument::from_content(&script)?;
let len = doc.len();
if len > 10 {
let doc_text = doc.text();
let start = find_char_boundary(&doc_text, len / 4);
let end = find_char_boundary(&doc_text, len / 2);
let range = Range::new(Position::new(start), Position::new(end));
doc.replace(range, &new_text)?;
doc.validate()?;
let expected_len = len - (end - start) + new_text.len();
prop_assert_eq!(doc.len(), expected_len);
}
}
#[test]
fn test_command_reversibility(
script in arb_ass_script(),
style_name in "[A-Za-z][A-Za-z0-9]{0,15}",
font_size in 10.0..50.0f32,
) {
let mut doc = EditorDocument::from_content(&script)?;
let original_text = doc.text();
let style_builder = StyleBuilder::default()
.font("Arial")
.size(font_size as u32);
let command = CreateStyleCommand::new(style_name, style_builder);
command.execute(&mut doc)?;
prop_assert_ne!(doc.text(), original_text.clone());
doc.undo()?;
prop_assert_eq!(doc.text(), original_text);
}
#[test]
fn test_batch_command_execution(
script in arb_ass_script(),
texts in prop::collection::vec(arb_text(), 2..5),
) {
let mut doc = EditorDocument::from_content(&script)?;
let non_empty_texts: Vec<_> = texts.iter()
.filter(|t| !t.is_empty())
.cloned()
.collect();
if non_empty_texts.is_empty() {
return Ok(());
}
let mut batch = BatchCommand::new("Test batch".to_string());
let doc_text = doc.text();
for (i, text) in non_empty_texts.iter().enumerate() {
let offset = (i * 100).min(doc.len());
let valid_offset = find_char_boundary(&doc_text, offset);
let pos = Position::new(valid_offset);
batch = batch.add_command(Box::new(
InsertTextCommand::new(pos, text.clone())
));
}
let result = batch.execute(&mut doc)?;
prop_assert!(result.success);
prop_assert!(result.content_changed);
for text in &non_empty_texts {
prop_assert!(doc.text().contains(text));
}
}
#[test]
fn test_position_validity_after_edits(
script in arb_ass_script(),
edits in prop::collection::vec((arb_text(), 0..100usize), 1..10),
) {
let mut doc = EditorDocument::from_content(&script)?;
for (text, offset_percent) in edits {
let max_offset = doc.len();
let raw_offset = (max_offset * offset_percent) / 100;
let doc_text = doc.text();
let offset = find_char_boundary(&doc_text, raw_offset.min(max_offset));
let pos = Position::new(offset);
doc.insert(pos, &text)?;
prop_assert_eq!(doc.len(), max_offset + text.len());
prop_assert!(Position::new(0).offset <= doc.len());
prop_assert!(Position::new(doc.len()).offset <= doc.len());
}
}
#[test]
fn test_search_edge_cases(
script in arb_ass_script(),
pattern in "[a-zA-Z]{1,10}",
case_sensitive in prop::bool::ANY,
whole_words in prop::bool::ANY,
) {
use ass_editor::utils::search::{DocumentSearch, DocumentSearchImpl, SearchOptions, SearchScope};
let doc = EditorDocument::from_content(&script)?;
let mut search = DocumentSearchImpl::new();
search.build_index(&doc)?;
let options = SearchOptions {
case_sensitive,
whole_words,
use_regex: false,
scope: SearchScope::All,
max_results: 100,
};
let results = search.search(&pattern, &options)?;
for result in results {
prop_assert!(result.start.offset <= doc.len());
prop_assert!(result.end.offset <= doc.len());
prop_assert!(result.start.offset <= result.end.offset);
}
}
#[test]
fn test_style_command_validation(
script in arb_ass_script(),
new_font_name in "[A-Za-z][A-Za-z0-9]{0,30}",
font_size in 1.0..200.0f32,
bold in prop::bool::ANY,
italic in prop::bool::ANY,
) {
let mut doc = EditorDocument::from_content(&script)?;
let first_style_name = doc.parse_script_with(|script| {
script.sections()
.iter()
.find_map(|section| match section {
ass_core::parser::Section::Styles(styles) => {
styles.first().map(|style| style.name.to_string())
},
_ => None,
})
})?;
if let Some(style_name) = first_style_name {
let command = EditStyleCommand::new(style_name)
.set_font(&new_font_name)
.set_size(font_size as u32)
.set_bold(bold)
.set_italic(italic);
let result = command.execute(&mut doc)?;
prop_assert!(result.success);
doc.validate()?;
}
}
#[test]
fn test_undo_stack_limits(
script in arb_ass_script(),
num_ops in 50..200usize,
) {
use ass_editor::core::UndoStackConfig;
let mut doc = EditorDocument::from_content(&script)?;
let config = UndoStackConfig {
max_entries: 10,
..Default::default()
};
doc.undo_manager_mut().set_config(config);
for i in 0..num_ops {
let doc_text = doc.text();
let raw_offset = i % doc.len();
let offset = find_char_boundary(&doc_text, raw_offset);
let pos = Position::new(offset);
doc.insert(pos, "X")?;
}
let mut undo_count = 0;
while doc.can_undo() && undo_count < 20 {
doc.undo()?;
undo_count += 1;
}
prop_assert!(undo_count <= 10);
}
#[cfg(feature = "stream")]
#[test]
fn test_incremental_consistency(
script in arb_ass_script(),
edits in prop::collection::vec((arb_text(), 0..100usize), 1..5),
) {
let mut doc_incremental = EditorDocument::from_content(&script)?;
let mut doc_regular = EditorDocument::from_content(&script)?;
for (text, offset_percent) in edits {
let max_offset = doc_incremental.len();
let raw_offset = (max_offset * offset_percent) / 100;
let doc_text = doc_incremental.text();
let offset = find_char_boundary(&doc_text, raw_offset.min(max_offset));
let pos = Position::new(offset);
let range = Range::new(pos, pos);
doc_incremental.edit_incremental(range, &text)?;
doc_regular.replace(range, &text)?;
prop_assert_eq!(doc_incremental.text(), doc_regular.text());
}
}
}