#![allow(missing_docs, reason = "test")]
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::todo,
clippy::unimplemented,
clippy::unreachable,
clippy::get_unwrap,
reason = "Panicking is acceptable and often desired in test, benchmark, and example code."
)]
mod common;
use citum_schema::reference::ClassExtension;
use common::*;
use citum_engine::{Processor, render::html::Html};
use citum_schema::{
CitationOptions, CitationSpec, Style, StyleInfo,
citation::{Citation, CitationItem, CitationMode, IntegralNameState},
grouping::{GroupSort, GroupSortEntry, GroupSortKey, SortKey as GroupSortKeyType},
options::{
AndOptions, Config, ContributorConfig, DelimiterPrecedesLast, DisplayAsSort, GivennameRule,
IntegralNameContexts, IntegralNameMemoryConfig, IntegralNameScope, NameForm, Processing,
ProcessingCustom, ShortenListOptions, SubsequentNameForm,
},
reference::InputReference,
};
fn build_numeric_style() -> Style {
Style {
info: StyleInfo {
title: Some("Numeric Test".to_string()),
id: Some("numeric-test".into()),
..Default::default()
},
options: Some(Config {
processing: Some(Processing::Numeric),
..Default::default()
}),
citation: Some(CitationSpec {
template: Some(vec![citum_schema::tc_number!(CitationNumber)]),
wrap: Some(citum_schema::template::WrapPunctuation::Brackets.into()),
..Default::default()
}),
..Default::default()
}
}
fn build_title_year_citation_style(sort: Vec<GroupSortKey>) -> Style {
Style {
info: StyleInfo {
title: Some("Title Year Citation Sort Test".to_string()),
id: Some("title-year-citation-sort-test".into()),
..Default::default()
},
options: Some(Config {
processing: Some(Processing::Numeric),
..Default::default()
}),
citation: Some(CitationSpec {
sort: Some(GroupSortEntry::Explicit(GroupSort { template: sort })),
template: Some(vec![
citum_schema::tc_title!(Primary),
citum_schema::tc_date!(Issued, Year),
]),
delimiter: Some(" ".to_string()),
multi_cite_delimiter: Some("; ".to_string()),
..Default::default()
}),
..Default::default()
}
}
fn build_integral_name_style() -> Style {
Style {
info: StyleInfo {
title: Some("Integral Name Memory".to_string()),
id: Some("integral-name-memory".into()),
..Default::default()
},
options: Some(Config {
processing: Some(Processing::AuthorDate),
integral_name_memory: Some(IntegralNameMemoryConfig {
scope: Some(IntegralNameScope::Document),
contexts: Some(IntegralNameContexts::BodyAndNotes),
subsequent_form: Some(SubsequentNameForm::Short),
..Default::default()
}),
..Default::default()
}),
citation: Some(CitationSpec {
integral: Some(Box::new(CitationSpec {
template: Some(vec![citum_schema::tc_contributor!(Author, Long)]),
..Default::default()
})),
template: Some(vec![
citum_schema::tc_contributor!(Author, Short),
citum_schema::tc_date!(
Issued,
Year,
wrap = citum_schema::template::WrapPunctuation::Parentheses
),
]),
..Default::default()
}),
..Default::default()
}
}
fn build_author_date_style_with_givenname_rule(rule: GivennameRule) -> Style {
let mut style = common::build_author_date_style(true, true, true, Some(3), Some(1));
if let Some(options) = style.options.as_mut()
&& let Some(Processing::Custom(custom)) = options.processing.as_mut()
&& let Some(disambiguate) = custom.disambiguate.as_mut()
{
disambiguate.givenname_rule = rule;
}
style
}
fn by_cite_scope_fixture() -> Vec<InputReference> {
vec![
make_book_multi_author(
"ASTHMA-A",
vec![
("Asthma", "Albert"),
("Bronchitis", "Brandon"),
("Cold", "Crispin"),
],
1990,
"Book A",
),
make_book_multi_author(
"ASTHMA-B",
vec![
("Asthma", "Albert"),
("Bronchitis", "Edward"),
("Cold", "Crispin"),
],
1990,
"Book B",
),
make_book_multi_author(
"DROPSY-A",
vec![
("Dropsy", "Devon"),
("Enteritis", "Edward"),
("Fever", "Xavier"),
],
2000,
"Book C",
),
make_book_multi_author(
"DROPSY-B",
vec![
("Dropsy", "Devon"),
("Enteritis", "Frank"),
("Fever", "Yves"),
],
2000,
"Book D",
),
]
}
fn processor_for_givenname_rule(rule: GivennameRule) -> Processor {
let mut bibliography = indexmap::IndexMap::new();
for reference in by_cite_scope_fixture() {
let id = reference.id().expect("fixture reference id").to_string();
bibliography.insert(id, reference);
}
Processor::new(
build_author_date_style_with_givenname_rule(rule),
bibliography,
)
}
fn process_citation_ids(processor: &Processor, ids: &[&str]) -> String {
processor
.process_citation(&Citation {
items: ids
.iter()
.map(|id| CitationItem {
id: (*id).to_string(),
..Default::default()
})
.collect(),
mode: CitationMode::NonIntegral,
..Default::default()
})
.expect("citation should render")
}
fn integral_name_state_overrides_processor_memory() {
let mut bibliography = indexmap::IndexMap::new();
bibliography.insert(
"item1".to_string(),
make_book("item1", "Smith", "John", 2020, "Book A"),
);
let processor = Processor::new(build_integral_name_style(), bibliography);
let first = Citation {
mode: CitationMode::Integral,
items: vec![CitationItem {
id: "item1".to_string(),
integral_name_state: Some(IntegralNameState::First),
..Default::default()
}],
..Default::default()
};
let subsequent = Citation {
mode: CitationMode::Integral,
items: vec![CitationItem {
id: "item1".to_string(),
integral_name_state: Some(IntegralNameState::Subsequent),
..Default::default()
}],
..Default::default()
};
assert_eq!(
processor
.process_citation(&first)
.expect("first should render"),
"John Smith"
);
assert_eq!(
processor
.process_citation(&subsequent)
.expect("subsequent should render"),
"Smith"
);
}
fn absent_memory_block_does_not_rewrite_subsequent_name_state() {
let mut bibliography = indexmap::IndexMap::new();
bibliography.insert(
"item1".to_string(),
make_book("item1", "Smith", "John", 2020, "Book A"),
);
let mut style = build_integral_name_style();
style.options.as_mut().unwrap().integral_name_memory = None;
let processor = Processor::new(style, bibliography);
let subsequent = Citation {
mode: CitationMode::Integral,
items: vec![CitationItem {
id: "item1".to_string(),
integral_name_state: Some(IntegralNameState::Subsequent),
..Default::default()
}],
..Default::default()
};
assert_eq!(
processor
.process_citation(&subsequent)
.expect("should render"),
"John Smith"
);
}
fn disambiguation_same_author_same_year_titles_follow_title_order() {
let input = vec![
make_book("item1", "Smith", "John", 2020, "Alpha"),
make_book("item2", "Smith", "John", 2020, "Beta"),
];
let citation_items = vec![vec!["item1", "item2"]];
let expected = "Smith, (2020a), (2020b)";
run_test_case_native(&input, &citation_items, expected, "citation");
}
#[allow(
clippy::too_many_lines,
reason = "test functions naturally exceed 100 lines"
)]
fn disambiguation_two_level_author_collisions_get_distinct_suffixes() {
let input = vec![
make_book_multi_author(
"ITEM-1",
vec![("Smith", "John"), ("Jones", "John"), ("Brown", "John")],
1986,
"Book A",
),
make_book_multi_author(
"ITEM-2",
vec![("Smith", "John"), ("Jones", "John"), ("Brown", "John")],
1986,
"Book B",
),
make_book_multi_author(
"ITEM-3",
vec![
("Smith", "John"),
("Jones", "John"),
("Brown", "John"),
("Green", "John"),
],
1986,
"Book C",
),
make_book_multi_author(
"ITEM-4",
vec![
("Smith", "John"),
("Jones", "John"),
("Brown", "John"),
("Green", "John"),
],
1986,
"Book D",
),
];
let mut style = build_author_date_style(true, true, false, Some(3), Some(1));
style.options = Some(Config {
processing: Some(Processing::Custom(ProcessingCustom {
disambiguate: Some(citum_schema::options::Disambiguation {
year_suffix: true,
names: true,
add_givenname: false,
givenname_rule: GivennameRule::default(),
}),
..Default::default()
})),
contributors: Some(ContributorConfig {
display_as_sort: Some(DisplayAsSort::First),
initialize_with: Some(String::new()),
shorten: Some(ShortenListOptions {
min: 3,
use_first: 1,
..Default::default()
}),
and: Some(AndOptions::Symbol),
delimiter_precedes_last: Some(DelimiterPrecedesLast::Never),
..Default::default()
}),
..Default::default()
});
style.citation = Some(CitationSpec {
sort: build_author_date_style(true, true, false, Some(3), Some(1))
.citation
.as_ref()
.and_then(|spec| spec.sort.clone()),
template: Some(vec![
citum_schema::tc_contributor!(Author, Short),
citum_schema::tc_date!(
Issued,
Year,
wrap = citum_schema::template::WrapPunctuation::Parentheses
),
]),
delimiter: Some(" ".to_string()),
multi_cite_delimiter: Some("; ".to_string()),
..Default::default()
});
let mut bibliography = indexmap::IndexMap::new();
for item in input {
if let Some(id) = item.id() {
bibliography.insert(id.to_string(), item);
}
}
let processor = Processor::new(style, bibliography);
let citation = Citation {
items: vec![
CitationItem {
id: "ITEM-1".to_string(),
..Default::default()
},
CitationItem {
id: "ITEM-2".to_string(),
..Default::default()
},
CitationItem {
id: "ITEM-3".to_string(),
..Default::default()
},
CitationItem {
id: "ITEM-4".to_string(),
..Default::default()
},
],
mode: CitationMode::NonIntegral,
..Default::default()
};
let result = processor
.process_citation(&citation)
.expect("Failed to process two-level year-suffix disambiguation citation");
assert_eq!(
result,
"Smith, Jones & Brown (1986a); Smith, Jones & Brown (1986b); Smith, Jones, Brown, et al. (1986a); Smith, Jones, Brown, et al. (1986b)"
);
}
fn disambiguation_same_year_articles_increment_suffixes() {
let input = vec![
make_article("22", "Ylinen", "A", 1995, "Article A"),
make_article("21", "Ylinen", "A", 1995, "Article B"),
make_article("23", "Ylinen", "A", 1995, "Article C"),
];
let citation_items = vec![vec!["22", "21", "23"]];
let expected = "Ylinen, (1995a), (1995b), (1995c)";
run_test_case_native(&input, &citation_items, expected, "citation");
}
fn disambiguation_no_spurious_givenname_expansion_when_years_differ() {
let input = vec![
make_book_multi_author(
"ITEM-1",
vec![("Asthma", "Albert"), ("Asthma", "Bridget")],
1980,
"Book A",
),
make_book("ITEM-2", "Bronchitis", "Beauregarde", 1995, "Book B"),
make_book("ITEM-3", "Asthma", "Albert", 1885, "Book C"),
];
let citation_items = vec![vec!["ITEM-1", "ITEM-2", "ITEM-3"]];
let expected = "Asthma, (1885); Asthma, Asthma, (1980); Bronchitis, (1995)";
run_test_case_native_with_options(common::TestCaseOptions {
input: &input,
citation_items: &citation_items,
expected,
mode: "citation",
disambiguate_year_suffix: false,
disambiguate_names: false,
disambiguate_givenname: true,
et_al_min: None,
et_al_use_first: None,
});
}
fn disambiguation_givenname_expansion_resolves_same_year_family_name_collision() {
let input = vec![
make_book("ITEM-A", "Smith", "Alice", 2000, "Book A"),
make_book("ITEM-B", "Smith", "Bob", 2000, "Book B"),
make_book("ITEM-C", "Jones", "Carol", 2000, "Book C"),
];
let citation_items = vec![vec!["ITEM-A", "ITEM-B", "ITEM-C"]];
run_test_case_native_with_options(common::TestCaseOptions {
input: &input,
citation_items: &citation_items,
expected: "Jones, (2000); A Smith, (2000); B Smith, (2000)",
mode: "citation",
disambiguate_year_suffix: false,
disambiguate_names: false,
disambiguate_givenname: true,
et_al_min: None,
et_al_use_first: None,
});
}
fn disambiguation_by_cite_givenname_expansion_is_citation_local() {
let processor = processor_for_givenname_rule(GivennameRule::ByCite);
let asthma = process_citation_ids(&processor, &["ASTHMA-A", "ASTHMA-B"]);
let dropsy = process_citation_ids(&processor, &["DROPSY-A"]);
assert_eq!(
asthma,
"A Asthma, B Bronchitis, et al., (1990); A Asthma, E Bronchitis, et al., (1990)"
);
assert_eq!(dropsy, "Dropsy et al., (2000)");
}
fn disambiguation_all_names_givenname_expansion_remains_global() {
let processor = processor_for_givenname_rule(GivennameRule::AllNames);
let dropsy = process_citation_ids(&processor, &["DROPSY-A"]);
assert_eq!(dropsy, "D Dropsy, E Enteritis, et al., (2000)");
}
fn disambiguation_by_cite_solo_cite_from_collision_group() {
let processor = processor_for_givenname_rule(GivennameRule::ByCite);
let solo = process_citation_ids(&processor, &["ASTHMA-A"]);
assert_eq!(solo, "Asthma et al., (1990)");
}
fn disambiguation_by_cite_mixed_groups_in_same_citation() {
let processor = processor_for_givenname_rule(GivennameRule::ByCite);
let mixed = process_citation_ids(&processor, &["ASTHMA-A", "ASTHMA-B", "DROPSY-A"]);
assert_eq!(
mixed,
"A Asthma, B Bronchitis, et al., (1990); A Asthma, E Bronchitis, et al., (1990); Dropsy et al., (2000)"
);
}
fn disambiguation_all_names_co_citation() {
let processor = processor_for_givenname_rule(GivennameRule::AllNames);
let co = process_citation_ids(&processor, &["DROPSY-A", "DROPSY-B"]);
assert_eq!(
co,
"D Dropsy, E Enteritis, et al., (2000); D Dropsy, F Enteritis, et al., (2000)"
);
}
fn disambiguation_primary_name_givenname_expansion() {
let processor = processor_for_givenname_rule(GivennameRule::PrimaryName);
let asthma = process_citation_ids(&processor, &["ASTHMA-A", "ASTHMA-B"]);
assert_eq!(
asthma,
"A Asthma, Bronchitis, et al., (1990a); A Asthma, Bronchitis, et al., (1990b)"
);
}
fn disambiguation_primary_name_givenname_expansion_resolves_distinct_primary_authors() {
let mut bibliography = indexmap::IndexMap::new();
for reference in [
make_book("ALICE", "Smith", "Alice", 2000, "Book A"),
make_book("BOB", "Smith", "Bob", 2000, "Book B"),
] {
let id = reference.id().expect("fixture reference id").to_string();
bibliography.insert(id, reference);
}
let processor = Processor::new(
build_author_date_style_with_givenname_rule(GivennameRule::PrimaryName),
bibliography,
);
let result = process_citation_ids(&processor, &["ALICE", "BOB"]);
assert_eq!(result, "A Smith, (2000); B Smith, (2000)");
}
fn disambiguation_et_al_conflicts_expand_names_when_that_resolves_them() {
let input = vec![
make_book_multi_author(
"ITEM-1",
vec![("Smith", "John"), ("Brown", "John"), ("Jones", "John")],
1980,
"Book A",
),
make_book_multi_author(
"ITEM-2",
vec![
("Smith", "John"),
("Beefheart", "Captain"),
("Jones", "John"),
],
1980,
"Book B",
),
];
let citation_items = vec![vec!["ITEM-1", "ITEM-2"]];
let expected = "Smith, Brown, et al., (1980); Smith, Beefheart, et al., (1980)";
run_test_case_native_with_options(common::TestCaseOptions {
input: &input,
citation_items: &citation_items,
expected,
mode: "citation",
disambiguate_year_suffix: false,
disambiguate_names: true,
disambiguate_givenname: false,
et_al_min: Some(3),
et_al_use_first: Some(1),
});
}
fn disambiguation_et_al_conflicts_fall_back_to_year_suffixes() {
let input = vec![
make_book_multi_author(
"ITEM-1",
vec![("Smith", "John"), ("Brown", "John"), ("Jones", "John")],
1980,
"Book A",
),
make_book_multi_author(
"ITEM-2",
vec![("Smith", "John"), ("Brown", "John"), ("Jones", "John")],
1980,
"Book B",
),
];
let citation_items = vec![vec!["ITEM-1", "ITEM-2"]];
let expected = "Smith et al., (1980a), (1980b)";
run_test_case_native_with_options(common::TestCaseOptions {
input: &input,
citation_items: &citation_items,
expected,
mode: "citation",
disambiguate_year_suffix: true,
disambiguate_names: true,
disambiguate_givenname: false,
et_al_min: Some(3),
et_al_use_first: Some(1),
});
}
fn disambiguation_initials_are_used_when_short_form_family_names_collide() {
let input = vec![
make_book("ITEM-1", "Roe", "Jane", 2000, "Book A"),
make_book("ITEM-2", "Doe", "John", 2000, "Book B"),
make_book("ITEM-3", "Doe", "Aloysius", 2000, "Book C"),
make_book("ITEM-4", "Smith", "Thomas", 2000, "Book D"),
make_book("ITEM-5", "Smith", "Ted", 2000, "Book E"),
];
let citation_items = vec![
vec!["ITEM-1"],
vec!["ITEM-2", "ITEM-3"],
vec!["ITEM-4", "ITEM-5"],
];
let expected = "Roe, (2000)
J Doe, (2000); A Doe, (2000)
T Smith, (2000); T Smith, (2000)";
run_test_case_native_with_options(common::TestCaseOptions {
input: &input,
citation_items: &citation_items,
expected,
mode: "citation",
disambiguate_year_suffix: false,
disambiguate_names: false,
disambiguate_givenname: true,
et_al_min: None,
et_al_use_first: None,
});
}
fn disambiguation_year_suffix_fallback_when_givenname_expansion_fails() {
let input = vec![
make_book("ITEM-4", "Smith", "Ted", 2000, "Book D"),
make_book("ITEM-5", "Smith", "Ted", 2000, "Book E"),
];
let citation_items = vec![vec!["ITEM-4", "ITEM-5"]];
run_test_case_native_with_options(common::TestCaseOptions {
input: &input,
citation_items: &citation_items,
expected: "Smith, (2000a), (2000b)",
mode: "citation",
disambiguate_year_suffix: true,
disambiguate_names: false,
disambiguate_givenname: true,
et_al_min: None,
et_al_use_first: None,
});
}
fn subsequent_et_al_thresholds_shorten_the_repeat_citation() {
use citum_schema::options::{Disambiguation, Processing, ProcessingCustom, ShortenListOptions};
let authors = vec![("Doe", "John"), ("Smith", "Jane"), ("Jones", "Alice")];
let item = make_book_multi_author("REF-1", authors, 2020, "A Multi-Author Book");
let mut bibliography = indexmap::IndexMap::new();
bibliography.insert("REF-1".to_string(), item);
let style = Style {
info: StyleInfo {
title: Some("Subsequent Et-Al Test".to_string()),
id: Some("subsequent-etal-test".into()),
..Default::default()
},
options: Some(Config {
processing: Some(Processing::Custom(ProcessingCustom {
disambiguate: Some(Disambiguation {
year_suffix: false,
names: false,
add_givenname: false,
givenname_rule: GivennameRule::default(),
}),
..Default::default()
})),
contributors: Some(citum_schema::options::ContributorConfig {
shorten: Some(ShortenListOptions {
min: 4,
use_first: 3,
subsequent_min: Some(2),
subsequent_use_first: Some(1),
..Default::default()
}),
initialize_with: Some(" ".to_string()),
..Default::default()
}),
..Default::default()
}),
citation: Some(CitationSpec {
template: Some(vec![
citum_schema::tc_contributor!(Author, Short),
citum_schema::tc_date!(
Issued,
Year,
wrap = citum_schema::template::WrapPunctuation::Parentheses
),
]),
multi_cite_delimiter: Some("; ".to_string()),
..Default::default()
}),
..Default::default()
};
let processor = Processor::new(style, bibliography);
let first_cite = Citation {
items: vec![CitationItem {
id: "REF-1".to_string(),
..Default::default()
}],
mode: CitationMode::NonIntegral,
..Default::default()
};
let repeat_cite = Citation {
items: vec![CitationItem {
id: "REF-1".to_string(),
..Default::default()
}],
mode: CitationMode::NonIntegral,
..Default::default()
};
let results = processor
.process_citations(&[first_cite, repeat_cite])
.expect("citations should render");
assert_eq!(
results[0], "Doe, Smith, Jones, (2020)",
"First citation should show all three authors without et al."
);
assert_eq!(
results[1], "Doe et al., (2020)",
"Subsequent citation should collapse to one author + et al."
);
}
fn disambiguation_year_suffix_assigned_when_et_al_truncation_leaves_collision() {
let input = vec![
make_article_multi_author(
"ITEM-1",
vec![
("Baur", "Bruno"),
("Fröberg", "Lars"),
("Baur", "Anette"),
("Guggenheim", "Richard"),
("Haase", "Martin"),
],
2000,
"Ultrastructure of snail grazing damage to calcicolous lichens",
),
make_article_multi_author(
"ITEM-2",
vec![
("Baur", "Bruno"),
("Schileyko", "Anatoly A."),
("Baur", "Anette"),
],
2000,
"Ecological observations on Arianta aethiops aethiops",
),
make_article("ITEM-3", "Doe", "John", 2000, "Some bogus title"),
];
let citation_items = vec![vec!["ITEM-1", "ITEM-2", "ITEM-3"]];
let expected = "Baur et al., (2000b); Baur et al., (2000a); Doe, (2000)";
run_test_case_native_with_options(common::TestCaseOptions {
input: &input,
citation_items: &citation_items,
expected,
mode: "citation",
disambiguate_year_suffix: true,
disambiguate_names: false,
disambiguate_givenname: false,
et_al_min: Some(3),
et_al_use_first: Some(1),
});
}
fn citation_scoped_contributor_shorten_applies_without_component_override() {
let item = make_book_multi_author(
"REF-1",
vec![
("Doe", "John"),
("Smith", "Jane"),
("Jones", "Alex"),
("Brown", "Casey"),
],
2020,
"Scoped Shorten",
);
let mut bibliography = indexmap::IndexMap::new();
bibliography.insert("REF-1".to_string(), item);
let style = Style {
info: StyleInfo {
title: Some("Scoped contributor shorten".to_string()),
id: Some("scoped-contributor-shorten".into()),
..Default::default()
},
citation: Some(CitationSpec {
options: Some(CitationOptions {
contributors: Some(ContributorConfig {
shorten: Some(ShortenListOptions {
min: 4,
use_first: 1,
and_others: citum_schema::options::AndOtherOptions::Text,
..Default::default()
}),
..Default::default()
}),
..Default::default()
}),
template: Some(vec![citum_schema::tc_contributor!(Author, Long)]),
..Default::default()
}),
..Default::default()
};
let processor = Processor::new(style, bibliography);
let rendered = processor
.process_citation(&Citation {
items: vec![CitationItem {
id: "REF-1".to_string(),
..Default::default()
}],
mode: CitationMode::NonIntegral,
..Default::default()
})
.expect("citation should render");
assert_eq!(
rendered, "John Doe et al",
"citation-scoped shorten should apply without component override"
);
}
fn disambiguation_identical_two_author_year_pair_receives_year_suffixes() {
let input = vec![
make_book_multi_author(
"ITEM-1",
vec![("Doe", "John"), ("Roe", "Jane")],
2000,
"Book A",
),
make_book_multi_author(
"ITEM-2",
vec![("Doe", "John"), ("Roe", "Jane")],
2000,
"Book B",
),
];
let citation_items = vec![vec!["ITEM-1", "ITEM-2"]];
let expected = "Doe, Roe, (2000a), (2000b)";
run_test_case_native(&input, &citation_items, expected, "citation");
}
fn disambiguation_suffixes_continue_past_z() {
let mut input = Vec::new();
let mut citation_ids = Vec::new();
for i in 1..=30 {
input.push(make_book(
&format!("ITEM-{i}"),
"Smith",
"John",
1986,
"Book",
));
citation_ids.push(format!("ITEM-{i}"));
}
let citation_items = vec![
citation_ids
.iter()
.map(std::string::String::as_str)
.collect(),
];
let expected = "Smith, (1986a), (1986b), (1986c), (1986d), (1986e), (1986f), (1986g), (1986h), (1986i), (1986j), (1986k), (1986l), (1986m), (1986n), (1986o), (1986p), (1986q), (1986r), (1986s), (1986t), (1986u), (1986v), (1986w), (1986x), (1986y), (1986z), (1986aa), (1986ab), (1986ac), (1986ad)";
run_test_case_native(&input, &citation_items, expected, "citation");
}
fn numeric_style_single_reference_renders_bracketed_number() {
let style = build_numeric_style();
let bib = citum_schema::bib_map![
"item1" => make_book("item1", "Smith", "John", 2020, "Title A"),
"item2" => make_book("item2", "Doe", "Jane", 2021, "Title B"),
];
let processor = Processor::new(style, bib);
assert_eq!(
processor
.process_citation(&citum_schema::cite!("item1"))
.unwrap(),
"[1]"
);
assert_eq!(
processor
.process_citation(&citum_schema::cite!("item2"))
.unwrap(),
"[2]"
);
}
fn author_date_sorting_orders_cluster_by_author_then_year() {
let input = vec![
make_book("item1", "Kuhn", "Thomas", 1962, "Title A"),
make_book("item2", "Hawking", "Stephen", 1988, "Title B"),
];
let citation_items = vec![vec!["item1", "item2"]];
let expected = "Hawking, (1988); Kuhn, (1962)";
run_test_case_native(&input, &citation_items, expected, "citation");
}
fn group_sorting_orders_cluster_by_year_within_an_author_group() {
let input = vec![
make_book("item1", "Kuhn", "Thomas", 1970, "Title A"),
make_book("item2", "Kuhn", "Thomas", 1962, "Title B"),
];
let citation_items = vec![vec!["item1", "item2"]];
let expected = "Kuhn, (1962), (1970)";
run_test_case_native(&input, &citation_items, expected, "citation");
}
#[cfg(feature = "icu")]
fn author_date_sorting_orders_cluster_with_unicode_surnames() {
let input = vec![
make_book("item1", "Zimring", "Craig", 2020, "Title A"),
make_book("item2", "Ó Tuathail", "Gearóid", 1998, "Title B"),
make_book("item3", "Çelik", "Zeynep", 1996, "Title C"),
];
let citation_items = vec![vec!["item1", "item2", "item3"]];
let expected = "Çelik, (1996); Ó Tuathail, (1998); Zimring, (2020)";
run_test_case_native(&input, &citation_items, expected, "citation");
}
fn sorting_empty_dates_pushes_undated_items_to_the_end() {
fn make_undated_book(id: &str, title: &str) -> InputReference {
let mut reference = make_book(id, "Smith", "Jane", 2000, title);
if let ClassExtension::Monograph(monograph) = reference.extension_mut() {
monograph.issued = citum_schema::reference::EdtfString(String::new());
}
reference
}
let style = build_title_year_citation_style(vec![
GroupSortKey {
key: GroupSortKeyType::Issued,
ascending: true,
order: None,
sort_order: None,
},
GroupSortKey {
key: GroupSortKeyType::Title,
ascending: true,
order: None,
sort_order: None,
},
]);
let mut bibliography = indexmap::IndexMap::new();
bibliography.insert("ITEM-1".to_string(), make_undated_book("ITEM-1", "BookA"));
bibliography.insert(
"ITEM-2".to_string(),
make_book("ITEM-2", "Smith", "Jane", 2000, "BookB"),
);
bibliography.insert("ITEM-3".to_string(), make_undated_book("ITEM-3", "BookC"));
bibliography.insert(
"ITEM-4".to_string(),
make_book("ITEM-4", "Smith", "Jane", 1999, "BookD"),
);
bibliography.insert("ITEM-5".to_string(), make_undated_book("ITEM-5", "BookE"));
let processor = Processor::new(style, bibliography);
let citation = Citation {
items: vec![
CitationItem {
id: "ITEM-1".to_string(),
..Default::default()
},
CitationItem {
id: "ITEM-2".to_string(),
..Default::default()
},
CitationItem {
id: "ITEM-3".to_string(),
..Default::default()
},
CitationItem {
id: "ITEM-4".to_string(),
..Default::default()
},
CitationItem {
id: "ITEM-5".to_string(),
..Default::default()
},
],
mode: CitationMode::NonIntegral,
..Default::default()
};
let result = processor
.process_citation(&citation)
.expect("Failed to process citation with empty-date sort");
assert_eq!(
result,
"BookD 1999; BookB 2000; BookA n.d.; BookC n.d.; BookE n.d."
);
}
fn disambiguation_multilingual_contributors_collide_on_original_family_name() {
let a = common::make_multilingual_book(common::MultilingualBookParams {
id: "ml-a",
original_family: "김",
original_given: "철수",
lang: "ko",
translit_script: "Latn",
translit_family: "Kim",
translit_given: "Cheolsu",
year: 2020,
title: "Book A",
});
let b = common::make_multilingual_book(common::MultilingualBookParams {
id: "ml-b",
original_family: "김",
original_given: "영희",
lang: "ko",
translit_script: "Latn",
translit_family: "Kim",
translit_given: "Yeonghui",
year: 2020,
title: "Book B",
});
let input = vec![a, b];
let citation_items = vec![vec!["ml-a", "ml-b"]];
run_test_case_native(
&input,
&citation_items,
"김, (2020a); 김, (2020b)",
"citation",
);
}
fn apa_reprint_issued_year_only_suffix() {
use citum_schema::reference::InputReference;
let make_reprint = |id: &str, orig_year: i32, title: &str| -> InputReference {
let json = serde_json::json!({
"id": id,
"type": "book",
"title": title,
"author": [{ "family": "Freud", "given": "Sigmund" }],
"issued": { "date-parts": [[1967]] },
"original-date": { "date-parts": [[orig_year]] }
});
let legacy: csl_legacy::csl_json::Reference =
serde_json::from_value(json).expect("reprint json parse");
legacy.into()
};
let refs = vec![
make_reprint("reprint-a", 1926, "Abriss der Psychoanalyse"),
make_reprint("reprint-b", 1926, "Begriffsbestimmung"),
make_reprint("reprint-c", 1927, "Zukunft einer Illusion"),
];
let style: citum_schema::Style = serde_yaml::from_str(
r"
info:
title: APA Reprint Suffix Test
id: test-apa-reprint
options:
processing:
disambiguate:
year-suffix: true
names: false
add-givenname: false
citation:
multi-cite-delimiter: ' '
template:
- group:
- date: original-published
form: year
- date: issued
form: year
delimiter: /
wrap:
punctuation: parentheses
",
)
.expect("reprint style parse");
let mut bibliography = indexmap::IndexMap::new();
for item in &refs {
if let Some(id) = item.id() {
bibliography.insert(id.to_string(), item.clone());
}
}
let processor = Processor::new(style, bibliography);
let citation = citum_schema::citation::Citation {
items: refs
.iter()
.filter_map(|r| r.id())
.map(|id| citum_schema::citation::CitationItem {
id: id.to_string(),
..Default::default()
})
.collect(),
mode: citum_schema::citation::CitationMode::NonIntegral,
..Default::default()
};
let result = processor
.process_citation(&citation)
.expect("Failed to process APA reprint citation");
assert_eq!(result, "Freud, (1926/1967a), (1926/1967b), (1927/1967c)");
}
fn chicago_notes_immediate_repeat_renders_compact_ibid() {
use std::path::PathBuf;
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("styles/embedded/chicago-notes-18th.yaml");
let yaml = std::fs::read_to_string(&path).expect("Failed to read chicago-notes.yaml");
let style: citum_schema::Style =
serde_yaml::from_str(&yaml).expect("Failed to parse chicago-notes.yaml");
let bib = citum_schema::bib_map![
"smith1995" => make_book("smith1995", "Smith", "John", 1995, "A Great Book"),
];
let processor = Processor::new(style, bib);
let first_citation = citum_schema::Citation {
items: vec![citum_schema::citation::CitationItem {
id: "smith1995".to_string(),
..Default::default()
}],
position: Some(citum_schema::citation::Position::First),
..Default::default()
};
let first_result = processor
.process_citation(&first_citation)
.expect("Failed to process first citation");
assert_eq!(first_result, "John Smith, _A Great Book_ (1995).");
let ibid_citation = citum_schema::Citation {
items: vec![citum_schema::citation::CitationItem {
id: "smith1995".to_string(),
..Default::default()
}],
position: Some(citum_schema::citation::Position::Ibid),
..Default::default()
};
let ibid_result = processor
.process_citation(&ibid_citation)
.expect("Failed to process ibid citation");
assert_eq!(ibid_result, "Ibid.");
}
fn chicago_notes_prefixed_ibid_remains_mid_sentence() {
use std::path::PathBuf;
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("styles/embedded/chicago-notes-18th.yaml");
let yaml = std::fs::read_to_string(&path).expect("Failed to read chicago-notes.yaml");
let style: citum_schema::Style =
serde_yaml::from_str(&yaml).expect("Failed to parse chicago-notes.yaml");
let bib = citum_schema::bib_map![
"smith1995" => make_book("smith1995", "Smith", "John", 1995, "A Great Book"),
];
let processor = Processor::new(style, bib);
let ibid_citation = citum_schema::Citation {
items: vec![citum_schema::citation::CitationItem {
id: "smith1995".to_string(),
..Default::default()
}],
position: Some(citum_schema::citation::Position::Ibid),
prefix: Some("See".to_string()),
..Default::default()
};
let ibid_result = processor
.process_citation(&ibid_citation)
.expect("Failed to process prefixed ibid citation");
assert!(
ibid_result.contains("See ibid."),
"prefixed ibid should remain mid-sentence lowercase: {ibid_result}"
);
assert!(
!ibid_result.contains("See Ibid."),
"prefixed ibid should not be capitalized as sentence-initial: {ibid_result}"
);
}
fn chicago_notes_immediate_repeat_with_locator_keeps_the_locator() {
use std::path::PathBuf;
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("styles/embedded/chicago-notes-18th.yaml");
let yaml = std::fs::read_to_string(&path).expect("Failed to read chicago-notes.yaml");
let style: citum_schema::Style =
serde_yaml::from_str(&yaml).expect("Failed to parse chicago-notes.yaml");
let bib = citum_schema::bib_map![
"smith1995" => make_book("smith1995", "Smith", "John", 1995, "A Great Book"),
];
let processor = Processor::new(style, bib);
let ibid_with_locator = citum_schema::Citation {
items: vec![citum_schema::citation::CitationItem {
id: "smith1995".to_string(),
locator: Some(citum_schema::citation::CitationLocator::single(
citum_schema::citation::LocatorType::Page,
"45",
)),
..Default::default()
}],
position: Some(citum_schema::citation::Position::IbidWithLocator),
..Default::default()
};
let result = processor
.process_citation(&ibid_with_locator)
.expect("Failed to process ibid with locator citation");
assert!(
result.contains("Ibid., 45"),
"IbidWithLocator should contain lexical ibid: {result}"
);
}
fn chicago_notes_non_immediate_repeat_uses_the_subsequent_short_form() {
use std::path::PathBuf;
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("styles/embedded/chicago-notes-18th.yaml");
let yaml = std::fs::read_to_string(&path).expect("Failed to read chicago-notes.yaml");
let style: citum_schema::Style =
serde_yaml::from_str(&yaml).expect("Failed to parse chicago-notes.yaml");
let bib = citum_schema::bib_map![
"smith1995" => make_book("smith1995", "Smith", "John", 1995, "A Great Book"),
];
let processor = Processor::new(style, bib);
let subsequent_citation = citum_schema::Citation {
items: vec![citum_schema::citation::CitationItem {
id: "smith1995".to_string(),
..Default::default()
}],
position: Some(citum_schema::citation::Position::Subsequent),
..Default::default()
};
let result = processor
.process_citation(&subsequent_citation)
.expect("Failed to process subsequent citation");
assert_eq!(result, "Smith, _A Great Book_.");
}
fn chicago_notes_reprint_full_note_renders_original_publisher_metadata() {
use std::path::PathBuf;
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("styles/embedded/chicago-notes-18th.yaml");
let yaml = std::fs::read_to_string(&path).expect("Failed to read chicago-notes.yaml");
let style: citum_schema::Style =
serde_yaml::from_str(&yaml).expect("Failed to parse chicago-notes.yaml");
let legacy: csl_legacy::csl_json::Reference = serde_json::from_value(serde_json::json!({
"id": "reprint1994",
"type": "book",
"title": "Orientalism",
"author": [{ "family": "Said", "given": "Edward W." }],
"issued": { "date-parts": [[1994]] },
"publisher": "Vintage Books",
"publisher-place": "New York",
"original-date": { "date-parts": [[1901]] },
"original-publisher": "Old Press",
"original-publisher-place": "Boston"
}))
.expect("failed to parse legacy reprint fixture");
let id = legacy.id.clone();
let bib = indexmap::IndexMap::from([(id.clone(), legacy.into())]);
let processor = Processor::new(style, bib);
let first_citation = citum_schema::Citation {
items: vec![citum_schema::citation::CitationItem {
id,
..Default::default()
}],
position: Some(citum_schema::citation::Position::First),
..Default::default()
};
let rendered = processor
.process_citation(&first_citation)
.expect("Failed to process reprint citation");
assert_eq!(
rendered,
"Edward W. Said, _Orientalism_ (1901) Old Press, Boston (Vintage Books, 1994)."
);
}
fn note_styles_without_ibid_overrides_fall_back_to_subsequent() {
let style = Style {
info: StyleInfo {
title: Some("Note Subsequent Fallback".to_string()),
id: Some("note-subsequent-fallback".into()),
..Default::default()
},
options: Some(Config {
processing: Some(Processing::Note),
..Default::default()
}),
citation: Some(CitationSpec {
template: Some(vec![citum_schema::tc_contributor!(Author, Long)]),
subsequent: Some(Box::new(CitationSpec {
template: Some(vec![citum_schema::tc_contributor!(Author, Short)]),
..Default::default()
})),
..Default::default()
}),
..Default::default()
};
let bib = citum_schema::bib_map![
"smith1995" => make_book("smith1995", "Smith", "John", 1995, "A Great Book"),
];
let processor = Processor::new(style, bib);
let subsequent = Citation {
items: vec![CitationItem {
id: "smith1995".to_string(),
..Default::default()
}],
position: Some(citum_schema::citation::Position::Subsequent),
..Default::default()
};
let ibid = Citation {
items: vec![CitationItem {
id: "smith1995".to_string(),
..Default::default()
}],
position: Some(citum_schema::citation::Position::Ibid),
..Default::default()
};
let ibid_with_locator = Citation {
items: vec![CitationItem {
id: "smith1995".to_string(),
locator: Some(citum_schema::citation::CitationLocator::single(
citum_schema::citation::LocatorType::Page,
"45",
)),
..Default::default()
}],
position: Some(citum_schema::citation::Position::IbidWithLocator),
..Default::default()
};
let subsequent_rendered = processor
.process_citation(&subsequent)
.expect("subsequent should render");
let ibid_rendered = processor
.process_citation(&ibid)
.expect("ibid should render");
let ibid_with_locator_rendered = processor
.process_citation(&ibid_with_locator)
.expect("ibid-with-locator should render");
assert_eq!(
ibid_rendered, subsequent_rendered,
"Ibid should fall back to subsequent form when `citation.ibid` is absent"
);
assert_eq!(
ibid_with_locator_rendered, subsequent_rendered,
"IbidWithLocator should fall back to subsequent form when `citation.ibid` is absent"
);
assert!(
!ibid_rendered.contains("Ibid"),
"fallback should not force lexical ibid output"
);
}
fn oscola_position_overrides_control_ibid_and_subsequent_forms() {
use std::path::PathBuf;
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("styles/oscola.yaml");
let yaml = std::fs::read_to_string(&path).expect("Failed to read oscola.yaml");
let style: citum_schema::Style =
serde_yaml::from_str(&yaml).expect("Failed to parse oscola.yaml");
let bib = citum_schema::bib_map![
"smith1995" => make_book("smith1995", "Smith", "John", 1995, "A Great Book"),
];
let processor = Processor::new(style, bib);
let first = Citation {
items: vec![CitationItem {
id: "smith1995".to_string(),
..Default::default()
}],
position: Some(citum_schema::citation::Position::First),
..Default::default()
};
let subsequent = Citation {
items: vec![CitationItem {
id: "smith1995".to_string(),
..Default::default()
}],
position: Some(citum_schema::citation::Position::Subsequent),
..Default::default()
};
let ibid = Citation {
items: vec![CitationItem {
id: "smith1995".to_string(),
..Default::default()
}],
position: Some(citum_schema::citation::Position::Ibid),
..Default::default()
};
let ibid_with_locator = Citation {
items: vec![CitationItem {
id: "smith1995".to_string(),
locator: Some(citum_schema::citation::CitationLocator::single(
citum_schema::citation::LocatorType::Page,
"45",
)),
..Default::default()
}],
position: Some(citum_schema::citation::Position::IbidWithLocator),
..Default::default()
};
let first_rendered = processor
.process_citation(&first)
.expect("first cite should render");
let subsequent_rendered = processor
.process_citation(&subsequent)
.expect("subsequent cite should render");
let ibid_rendered = processor
.process_citation(&ibid)
.expect("ibid cite should render");
let ibid_with_locator_rendered = processor
.process_citation(&ibid_with_locator)
.expect("ibid-with-locator cite should render");
assert_eq!(
first_rendered,
"John Smith, \u{201C}_A Great Book_\u{201D}(1995)."
);
assert_eq!(
subsequent_rendered,
"Smith, \u{201C}_A Great Book_\u{201D}."
);
assert_eq!(ibid_rendered, "ibid.");
assert_eq!(ibid_with_locator_rendered, "ibid p45.");
}
fn oscola_without_ibid_reuses_the_subsequent_form_for_immediate_repeats() {
use std::path::PathBuf;
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("styles/oscola-no-ibid.yaml");
let yaml = std::fs::read_to_string(&path).expect("Failed to read oscola-no-ibid.yaml");
let style: citum_schema::Style =
serde_yaml::from_str(&yaml).expect("Failed to parse oscola-no-ibid.yaml");
let bib = citum_schema::bib_map![
"smith1995" => make_book("smith1995", "Smith", "John", 1995, "A Great Book"),
];
let processor = Processor::new(style, bib);
let subsequent = Citation {
items: vec![CitationItem {
id: "smith1995".to_string(),
..Default::default()
}],
position: Some(citum_schema::citation::Position::Subsequent),
..Default::default()
};
let ibid = Citation {
items: vec![CitationItem {
id: "smith1995".to_string(),
..Default::default()
}],
position: Some(citum_schema::citation::Position::Ibid),
..Default::default()
};
let subsequent_rendered = processor
.process_citation(&subsequent)
.expect("subsequent cite should render");
let ibid_rendered = processor
.process_citation(&ibid)
.expect("ibid cite should render");
assert_eq!(
ibid_rendered, subsequent_rendered,
"OSCOLA no-ibid should fall back to the subsequent form for immediate repeats"
);
assert!(
!ibid_rendered.to_lowercase().contains("ibid"),
"OSCOLA no-ibid should never render lexical ibid: {ibid_rendered}"
);
}
fn thomson_reuters_subsequent_short_form_keeps_the_locator() {
use std::path::PathBuf;
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("styles/thomson-reuters-legal-tax-and-accounting-australia.yaml");
let yaml = std::fs::read_to_string(&path)
.expect("Failed to read thomson-reuters-legal-tax-and-accounting-australia.yaml");
let style: citum_schema::Style = serde_yaml::from_str(&yaml)
.expect("Failed to parse thomson-reuters-legal-tax-and-accounting-australia.yaml");
let bib = citum_schema::bib_map![
"smith1995" => make_book("smith1995", "Smith", "John", 1995, "A Great Book"),
];
let processor = Processor::new(style, bib);
let first = Citation {
items: vec![CitationItem {
id: "smith1995".to_string(),
..Default::default()
}],
position: Some(citum_schema::citation::Position::First),
..Default::default()
};
let subsequent = Citation {
items: vec![CitationItem {
id: "smith1995".to_string(),
locator: Some(citum_schema::citation::CitationLocator::single(
citum_schema::citation::LocatorType::Page,
"23",
)),
..Default::default()
}],
position: Some(citum_schema::citation::Position::Subsequent),
..Default::default()
};
let first_rendered = processor
.process_citation(&first)
.expect("first cite should render");
let subsequent_rendered = processor
.process_citation(&subsequent)
.expect("subsequent cite should render");
assert_eq!(first_rendered, "Smith, \u{201C}A Great Book\u{201D}(1995).");
assert_eq!(
subsequent_rendered,
"Smith, \u{201C}_A Great Book_\u{201D} at 23."
);
}
fn grouped_author_date_mode_groups_items_by_author() {
let input = vec![
make_book("item1", "Smith", "John", 2020, "Book A"),
make_book("item1b", "Smith", "John", 2021, "Book B"),
make_book("item2", "Jones", "Jane", 2020, "Book C"),
];
let citation_items = vec![vec!["item1", "item1b", "item2"]];
let expected = "Jones, (2020); Smith, (2020), (2021)";
run_test_case_native(&input, &citation_items, expected, "citation");
}
fn grouped_numeric_mode_preserves_item_order() {
let input = vec![
make_book("item1", "Smith", "John", 2020, "Book A"),
make_book("item2", "Jones", "Jane", 2021, "Book B"),
make_book("item3", "Brown", "Bob", 2022, "Book C"),
];
let citation_items = vec![vec!["item1", "item2", "item3"]];
let expected = "Brown, (2022); Jones, (2021); Smith, (2020)";
run_test_case_native(&input, &citation_items, expected, "citation");
}
fn grouped_integral_mode_displays_first_author_only() {
let input = vec![
make_book("item1", "Smith", "John", 2020, "Book A"),
make_book("item1b", "Smith", "John", 2021, "Book B"),
];
let citation_items = vec![vec!["item1", "item1b"]];
let expected = "Smith, (2020), (2021)";
run_test_case_native(&input, &citation_items, expected, "citation");
}
fn citation_html_injects_sparse_template_indices_when_enabled() {
let style_yaml = r#"
info:
title: Indexed Citation Preview
id: indexed-citation-preview
citation:
template:
- title: primary
- variable: doi
prefix: ". "
- variable: url
prefix: " "
"#;
let style: Style = serde_yaml::from_str(style_yaml).expect("style should parse");
let legacy: csl_legacy::csl_json::Reference = serde_json::from_value(serde_json::json!({
"id": "ITEM-1",
"type": "book",
"title": "Preview Book",
"URL": "https://example.com/preview-book"
}))
.expect("legacy fixture should parse");
let mut bib = indexmap::IndexMap::new();
bib.insert("ITEM-1".to_string(), legacy.into());
let processor = Processor::new(style, bib).with_inject_ast_indices(true);
let citation = Citation {
items: vec![CitationItem {
id: "ITEM-1".to_string(),
..Default::default()
}],
..Default::default()
};
let rendered = processor
.process_citation_with_format::<Html>(&citation)
.expect("citation should render");
assert!(
rendered.contains(r#"class="citum-title" data-index="0""#),
"title wrapper should carry the first template index: {rendered}"
);
assert!(
rendered.contains(r#"class="citum-url" data-index="2""#),
"url wrapper should carry the sparse third template index: {rendered}"
);
assert!(
!rendered.contains(r#"data-index="1""#),
"missing DOI output should preserve sparse template indices: {rendered}"
);
}
mod integral_name_memory {
use super::announce_behavior;
#[test]
fn explicit_integral_name_state_overrides_processor_memory() {
announce_behavior(
"An explicit integral-name state should force full-form on first cite and short-form on repeat.",
);
super::integral_name_state_overrides_processor_memory();
}
#[test]
fn absent_memory_block_does_not_rewrite_subsequent_name_state() {
announce_behavior(
"A style with no integral-name-memory block should leave Subsequent-state citations rendered in the integral template's natural form.",
);
super::absent_memory_block_does_not_rewrite_subsequent_name_state();
}
}
mod disambiguation {
use super::announce_behavior;
#[test]
fn same_author_same_year_titles_follow_title_order() {
announce_behavior(
"Two same-author, same-year works should receive year suffixes in title order.",
);
super::disambiguation_same_author_same_year_titles_follow_title_order();
}
#[test]
fn two_level_author_collisions_get_distinct_suffixes() {
announce_behavior(
"Colliding author lists at multiple truncation levels should still end up with distinct year suffixes.",
);
super::disambiguation_two_level_author_collisions_get_distinct_suffixes();
}
#[test]
fn same_year_articles_increment_suffixes() {
announce_behavior(
"Same-year articles should increment year suffixes a, b, c in citation order.",
);
super::disambiguation_same_year_articles_increment_suffixes();
}
#[test]
fn no_spurious_givenname_expansion_when_years_differ() {
announce_behavior(
"When same-family-name refs already differ by year, add_givenname must not introduce spurious given-name expansion.",
);
super::disambiguation_no_spurious_givenname_expansion_when_years_differ();
}
#[test]
fn givenname_expansion_resolves_same_year_family_name_collision() {
announce_behavior(
"Two same-year authors with the same family name but different given names must have initials injected for the ambiguous pair; unrelated authors stay unexpanded.",
);
super::disambiguation_givenname_expansion_resolves_same_year_family_name_collision();
}
#[test]
fn by_cite_givenname_expansion_is_citation_local() {
announce_behavior(
"By-cite given-name disambiguation should expand only names needed by the current citation.",
);
super::disambiguation_by_cite_givenname_expansion_is_citation_local();
}
#[test]
fn all_names_givenname_expansion_remains_global() {
announce_behavior(
"All-names given-name disambiguation should keep document-wide expansion for affected name groups.",
);
super::disambiguation_all_names_givenname_expansion_remains_global();
}
#[test]
fn by_cite_solo_cite_from_collision_group_stays_unexpanded() {
announce_behavior(
"A solo by-cite citation of a reference that is in a global collision group must not expand — no collision exists in this citation's scope.",
);
super::disambiguation_by_cite_solo_cite_from_collision_group();
}
#[test]
fn by_cite_mixed_groups_expand_colliders_not_solos() {
announce_behavior(
"When a by-cite citation mixes two colliding references with a third unrelated reference, only the colliding pair expands; the solo reference stays unexpanded.",
);
super::disambiguation_by_cite_mixed_groups_in_same_citation();
}
#[test]
fn all_names_co_citation_expands_both_colliders() {
announce_behavior(
"Citing two globally-colliding references together under all-names must expand both.",
);
super::disambiguation_all_names_co_citation();
}
#[test]
fn primary_name_givenname_expansion_expands_first_author_only() {
announce_behavior(
"primary-name rule must expand the first author's given name; when that does not resolve the collision, year-suffix must be applied.",
);
super::disambiguation_primary_name_givenname_expansion();
}
#[test]
fn primary_name_givenname_expansion_resolves_when_primary_authors_differ() {
announce_behavior(
"primary-name rule must resolve the collision via given-name expansion alone when the primary authors' given names differ, with no year-suffix applied.",
);
super::disambiguation_primary_name_givenname_expansion_resolves_distinct_primary_authors();
}
#[test]
fn et_al_conflicts_expand_names_when_that_resolves_them() {
announce_behavior(
"When et al. creates a collision, name expansion should win if it can resolve the ambiguity.",
);
super::disambiguation_et_al_conflicts_expand_names_when_that_resolves_them();
}
#[test]
fn et_al_conflicts_fall_back_to_year_suffixes() {
announce_behavior(
"When et al. collisions cannot be resolved by names alone, year suffixes should disambiguate the cites.",
);
super::disambiguation_et_al_conflicts_fall_back_to_year_suffixes();
}
#[test]
fn initials_are_used_when_short_form_family_names_collide() {
announce_behavior(
"Short-form family-name collisions should expand to initials when that is the configured fallback.",
);
super::disambiguation_initials_are_used_when_short_form_family_names_collide();
}
#[test]
fn year_suffix_fallback_when_givenname_expansion_fails() {
announce_behavior(
"When given-name expansion cannot resolve a collision (same given name and family name), year-suffix must be applied as the next cascade step.",
);
super::disambiguation_year_suffix_fallback_when_givenname_expansion_fails();
}
#[test]
fn subsequent_et_al_thresholds_shorten_the_repeat_citation() {
announce_behavior(
"Subsequent-citation et al. thresholds should shorten a repeat citation more aggressively than the first cite.",
);
super::subsequent_et_al_thresholds_shorten_the_repeat_citation();
}
#[test]
fn year_suffix_assigned_when_et_al_truncation_leaves_collision() {
announce_behavior(
"When et-al truncation collapses distinct author lists to the same prefix, the resulting collision group should still receive distinct year suffixes in title sort order.",
);
super::disambiguation_year_suffix_assigned_when_et_al_truncation_leaves_collision();
}
#[test]
fn identical_two_author_year_pair_receives_year_suffixes() {
announce_behavior(
"Two works sharing the same two-author list and issued year should each receive a year suffix.",
);
super::disambiguation_identical_two_author_year_pair_receives_year_suffixes();
}
#[test]
fn suffixes_continue_past_z() {
announce_behavior(
"Year suffix generation should continue past z without resetting or truncating.",
);
super::disambiguation_suffixes_continue_past_z();
}
#[test]
fn multilingual_contributors_collide_on_original_family_name() {
announce_behavior(
"Multilingual contributors with matching original family names and the same issued year must form a collision group and receive year suffixes.",
);
super::disambiguation_multilingual_contributors_collide_on_original_family_name();
}
#[test]
fn apa_reprint_year_suffix_attaches_to_issued_year_only() {
announce_behavior(
"APA §8.15 reprints with different original-dates should receive year-suffix on the \
issued year only, producing (1926/1967a) (1926/1967b) (1927/1967c).",
);
super::apa_reprint_issued_year_only_suffix();
}
}
mod contributor_scoping {
use super::announce_behavior;
#[test]
fn citation_scoped_shorten_applies_without_component_override() {
announce_behavior(
"Citation-scoped contributor shortening should apply even when the template contributor has no explicit shorten block.",
);
super::citation_scoped_contributor_shorten_applies_without_component_override();
}
}
mod numeric_style {
use super::announce_behavior;
#[test]
fn single_reference_renders_bracketed_number() {
announce_behavior(
"A numeric citation style should render a single reference number in brackets.",
);
super::numeric_style_single_reference_renders_bracketed_number();
}
}
mod sorting_and_grouping {
use super::announce_behavior;
#[test]
fn author_date_sorting_orders_cluster_by_author_then_year() {
announce_behavior(
"Author-date citation clusters should sort entries by author and then by year.",
);
super::author_date_sorting_orders_cluster_by_author_then_year();
}
#[test]
fn group_sorting_orders_cluster_by_year_within_an_author_group() {
announce_behavior(
"Grouped citation sorting should keep works together by author and then sort years within that group.",
);
super::group_sorting_orders_cluster_by_year_within_an_author_group();
}
#[test]
#[cfg(feature = "icu")]
fn author_date_sorting_orders_cluster_with_unicode_surnames() {
announce_behavior(
"Author-date citation clusters should sort accented surnames with Unicode-aware collation.",
);
super::author_date_sorting_orders_cluster_with_unicode_surnames();
}
#[test]
fn empty_dates_push_undated_items_to_the_end() {
announce_behavior(
"Undated items should sort after dated items rather than interleaving with them.",
);
super::sorting_empty_dates_pushes_undated_items_to_the_end();
}
}
mod note_style_positions {
use super::announce_behavior;
#[test]
fn chicago_notes_immediate_repeat_renders_compact_ibid() {
announce_behavior("An immediate Chicago note repeat should collapse to a compact ibid.");
super::chicago_notes_immediate_repeat_renders_compact_ibid();
}
#[test]
fn chicago_notes_immediate_repeat_with_locator_keeps_the_locator() {
announce_behavior(
"An immediate Chicago note repeat with a locator should keep the locator in the ibid form.",
);
super::chicago_notes_immediate_repeat_with_locator_keeps_the_locator();
}
#[test]
fn chicago_notes_prefixed_ibid_remains_mid_sentence() {
announce_behavior(
"A prefixed Chicago ibid should stay lowercase because the note marker is no longer sentence-initial.",
);
super::chicago_notes_prefixed_ibid_remains_mid_sentence();
}
#[test]
fn chicago_notes_non_immediate_repeat_uses_the_subsequent_short_form() {
announce_behavior(
"A non-immediate Chicago note repeat should use the shortened subsequent-note form instead of ibid.",
);
super::chicago_notes_non_immediate_repeat_uses_the_subsequent_short_form();
}
#[test]
fn chicago_notes_reprint_full_note_renders_original_publisher_metadata() {
announce_behavior(
"A full Chicago note for a reprint should include original publisher metadata before the current publication details.",
);
super::chicago_notes_reprint_full_note_renders_original_publisher_metadata();
}
#[test]
fn note_styles_without_ibid_overrides_fall_back_to_subsequent() {
announce_behavior(
"Note styles without ibid overrides should fall back to their normal subsequent-note form.",
);
super::note_styles_without_ibid_overrides_fall_back_to_subsequent();
}
#[test]
fn oscola_position_overrides_control_ibid_and_subsequent_forms() {
announce_behavior(
"OSCOLA note-position overrides should decide when to emit ibid versus a subsequent short form.",
);
super::oscola_position_overrides_control_ibid_and_subsequent_forms();
}
#[test]
fn oscola_without_ibid_reuses_the_subsequent_form_for_immediate_repeats() {
announce_behavior(
"When OSCOLA disables ibid, even immediate repeats should reuse the subsequent short form.",
);
super::oscola_without_ibid_reuses_the_subsequent_form_for_immediate_repeats();
}
#[test]
fn thomson_reuters_subsequent_short_form_keeps_the_locator() {
announce_behavior(
"Thomson Reuters repeated notes should shorten the cite while preserving the locator.",
);
super::thomson_reuters_subsequent_short_form_keeps_the_locator();
}
#[test]
fn grouped_author_date_mode_groups_items_by_author() {
announce_behavior(
"Author-date grouped rendering should collapse multiple items with same author.",
);
super::grouped_author_date_mode_groups_items_by_author();
}
#[test]
fn grouped_numeric_mode_preserves_item_order() {
announce_behavior(
"Numeric grouped rendering should maintain citation order without author collapse.",
);
super::grouped_numeric_mode_preserves_item_order();
}
#[test]
fn grouped_integral_mode_displays_first_author_only() {
announce_behavior(
"Integral grouped rendering should display only the first item's author.",
);
super::grouped_integral_mode_displays_first_author_only();
}
#[test]
fn disambiguate_only_title_suppressed_when_first_ref_note_number_is_present() {
announce_behavior(
"In a note style, a `disambiguate-only` title should be suppressed in a subsequent \
citation when a first-reference-note-number is available — the note number \
already identifies the work.",
);
super::disambiguate_only_title_suppressed_in_note_cross_ref_position();
}
#[test]
fn disambiguate_only_title_kept_when_template_lacks_first_ref_note_number() {
announce_behavior(
"In a note style, a `disambiguate-only` title must be retained in a subsequent \
citation when the template does not render a first-reference-note-number — \
suppressing it would reintroduce ambiguity with no replacement identifier.",
);
super::disambiguate_only_title_kept_when_template_lacks_note_number();
}
}
mod annotated_html_preview {
use super::announce_behavior;
#[test]
fn citation_indices_stay_sparse_when_template_components_do_not_render() {
announce_behavior(
"Annotated citation HTML should preserve the original template indices when intermediate components do not render.",
);
super::citation_html_injects_sparse_template_indices_when_enabled();
}
}
#[test]
fn test_personal_communication_citation_rendering_is_style_driven() {
let bib_vec = serde_yaml::from_str::<Vec<InputReference>>(
r#"
- id: oglethorpe-1733
class: monograph
type: personal-communication
contributors:
- role: author
contributor: {given: James, family: Oglethorpe}
- role: recipient
contributor: {name: "the Trustees"}
issued: '1733-01-13'
"#,
)
.unwrap();
let mut bib = indexmap::IndexMap::new();
for item in bib_vec {
bib.insert(item.id().unwrap().to_string(), item);
}
let apa_style = Style {
info: StyleInfo {
title: Some("APA Personal Communication".to_string()),
..Default::default()
},
citation: Some(CitationSpec {
template: Some(vec![
citum_schema::template::TemplateComponent::Contributor(
citum_schema::template::TemplateContributor {
contributor: citum_schema::template::ContributorRole::Author,
form: citum_schema::template::ContributorForm::Long,
name_order: Some(citum_schema::template::NameOrder::GivenFirst),
rendering: citum_schema::template::Rendering {
name_form: Some(NameForm::Initials),
..Default::default()
},
..Default::default()
},
),
citum_schema::tc_term!(PersonalCommunication),
citum_schema::tc_date!(Issued, Full),
]),
delimiter: Some(", ".to_string()),
wrap: Some(citum_schema::template::WrapPunctuation::Parentheses.into()),
..Default::default()
}),
..Default::default()
};
let processor = Processor::new(apa_style, bib);
let citation = Citation {
items: vec![CitationItem {
id: "oglethorpe-1733".to_string(),
..Default::default()
}],
..Default::default()
};
let output = processor.process_citation(&citation).unwrap();
assert_eq!(
output,
"(J. Oglethorpe, personal communication, January 13, 1733)"
);
}
fn disambiguate_only_title_suppressed_in_note_cross_ref_position() {
let style: citum_schema::Style = serde_yaml::from_str(
r"
info:
title: Note Disambig-Only Test
id: test-note-disambig-only
options:
processing: note
citation:
template:
- contributor: author
form: short
- title: primary
form: short
disambiguate-only: true
delimiter: ', '
subsequent:
template:
- contributor: author
form: short
- title: primary
form: short
disambiguate-only: true
- number: first-reference-note-number
prefix: 'see n. '
delimiter: ', '
",
)
.expect("style parse");
let bib = citum_schema::bib_map![
"rome" => make_book("rome", "Smith", "John", 2020, "A History of Rome"),
"greece" => make_book("greece", "Smith", "John", 2020, "A History of Greece"),
];
let processor = Processor::new(style, bib);
let citations = vec![
citum_schema::citation::Citation {
items: vec![citum_schema::citation::CitationItem {
id: "rome".to_string(),
..Default::default()
}],
note_number: Some(1),
..Default::default()
},
citum_schema::citation::Citation {
items: vec![citum_schema::citation::CitationItem {
id: "greece".to_string(),
..Default::default()
}],
note_number: Some(2),
..Default::default()
},
citum_schema::citation::Citation {
items: vec![citum_schema::citation::CitationItem {
id: "rome".to_string(),
..Default::default()
}],
note_number: Some(3),
position: Some(citum_schema::citation::Position::Subsequent),
..Default::default()
},
];
let results = processor
.process_citations(&citations)
.expect("citations should render");
assert_eq!(results.len(), 3, "expected three rendered citations");
assert_eq!(
results,
vec![
"Smith, A History of Rome".to_string(),
"Smith, A History of Greece".to_string(),
"Smith, see n. 1".to_string(),
]
);
}
fn disambiguate_only_title_kept_when_template_lacks_note_number() {
let style: citum_schema::Style = serde_yaml::from_str(
r"
info:
title: Note Disambig-Only No-Number Test
id: test-note-disambig-only-no-number
options:
processing: note
citation:
template:
- contributor: author
form: short
- title: primary
form: short
disambiguate-only: true
delimiter: ', '
subsequent:
template:
- contributor: author
form: short
- title: primary
form: short
disambiguate-only: true
delimiter: ', '
",
)
.expect("style parse");
let bib = citum_schema::bib_map![
"rome" => make_book("rome", "Smith", "John", 2020, "A History of Rome"),
"greece" => make_book("greece", "Smith", "John", 2020, "A History of Greece"),
];
let processor = Processor::new(style, bib);
let citations = vec![
citum_schema::citation::Citation {
items: vec![citum_schema::citation::CitationItem {
id: "rome".to_string(),
..Default::default()
}],
note_number: Some(1),
..Default::default()
},
citum_schema::citation::Citation {
items: vec![citum_schema::citation::CitationItem {
id: "greece".to_string(),
..Default::default()
}],
note_number: Some(2),
..Default::default()
},
citum_schema::citation::Citation {
items: vec![citum_schema::citation::CitationItem {
id: "rome".to_string(),
..Default::default()
}],
note_number: Some(3),
position: Some(citum_schema::citation::Position::Subsequent),
..Default::default()
},
];
let results = processor
.process_citations(&citations)
.expect("citations should render");
assert_eq!(
results,
vec![
"Smith, A History of Rome".to_string(),
"Smith, A History of Greece".to_string(),
"Smith, A History of Rome".to_string(),
]
);
}
#[test]
fn test_sentence_start_capitalizes_lowercase_prefix() {
let style = build_author_date_style(false, false, false, None, None);
let bib = citum_schema::bib_map![
"smith2020" => make_book("smith2020", "Smith", "John", 2020, "A Book"),
];
let processor = Processor::new(style, bib);
let citation = Citation {
mode: CitationMode::Integral,
prefix: Some("see also".to_string()),
sentence_start: true,
items: vec![CitationItem {
id: "smith2020".to_string(),
..Default::default()
}],
..Default::default()
};
let result = processor.process_citation(&citation).expect("render");
assert!(
result.starts_with("See also"),
"expected 'See also …' but got: {result}"
);
}
#[test]
fn test_sentence_start_noop_on_capitalized_author() {
let style = build_author_date_style(false, false, false, None, None);
let bib = citum_schema::bib_map![
"smith2020" => make_book("smith2020", "Smith", "John", 2020, "A Book"),
];
let processor = Processor::new(style, bib);
let citation = Citation {
mode: CitationMode::Integral,
sentence_start: true,
items: vec![CitationItem {
id: "smith2020".to_string(),
..Default::default()
}],
..Default::default()
};
let without_flag = Citation {
sentence_start: false,
..citation.clone()
};
let with_result = processor
.process_citation(&citation)
.expect("render with flag");
let without_result = processor
.process_citation(&without_flag)
.expect("render without flag");
assert_eq!(
with_result, without_result,
"sentence_start should be a no-op when the cluster already starts with a capital"
);
}
#[test]
fn test_sentence_start_false_leaves_output_unchanged() {
let style = build_author_date_style(false, false, false, None, None);
let bib = citum_schema::bib_map![
"smith2020" => make_book("smith2020", "Smith", "John", 2020, "A Book"),
];
let processor = Processor::new(style, bib);
let citation = Citation {
mode: CitationMode::Integral,
prefix: Some("see also".to_string()),
sentence_start: false,
items: vec![CitationItem {
id: "smith2020".to_string(),
..Default::default()
}],
..Default::default()
};
let result = processor.process_citation(&citation).expect("render");
assert!(
result.starts_with("see also"),
"expected 'see also …' (lowercase) but got: {result}"
);
}