use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::collections::VecDeque;
use omgkit_core::{element, AtomFlags, BondFlags, BondOrder, MolBuilder};
use crate::valence::{explicit_valence_nonstrict, implicit_hs_nonstrict, total_valence_nonstrict};
fn backtrack_budget(system_size: usize) -> usize {
10_000 + 1_000 * system_size
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum KekulizeError {
CannotKekulize {
atoms: Vec<u32>,
},
SearchBudgetExhausted {
system_size: usize,
backtracks: usize,
},
NonRingAromaticAtom {
atom: u32,
},
ValenceChanged {
atom: u32,
before: i32,
after: i32,
},
UnsupportedAromaticDummy {
atom: u32,
},
}
impl core::fmt::Display for KekulizeError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::CannotKekulize { atoms } => {
write!(f, "该芳香体系不存在合法 Kekulé 结构,未配平的原子:{atoms:?}")
}
Self::SearchBudgetExhausted {
system_size,
backtracks,
} => write!(
f,
"kekulize 搜索触到安全上限({backtracks} 次回溯,体系 {system_size} 原子),\
结果未知 —— 这不等于无解"
),
Self::NonRingAromaticAtom { atom } => {
write!(f, "原子 #{atom} 不在环中却被标记为芳香")
}
Self::ValenceChanged {
atom,
before,
after,
} => write!(f, "kekulize 改变了原子 #{atom} 的总价:{before} → {after}"),
Self::UnsupportedAromaticDummy { atom } => {
write!(f, "原子 #{atom} 是芳香体系中的通配原子;该路径尚未实现")
}
}
}
}
impl std::error::Error for KekulizeError {}
pub fn kekulize(mol: &mut MolBuilder) -> Result<(), KekulizeError> {
let has_aromatic = mol
.bonds()
.iter()
.any(|b| b.flags.contains(BondFlags::AROMATIC) || b.order == BondOrder::Aromatic)
|| (0..mol.num_atoms() as u32).any(|i| is_aromatic_atom(mol, i));
if !has_aromatic {
return Ok(());
}
let before: Vec<i32> = (0..mol.num_atoms() as u32)
.map(|i| total_valence_nonstrict(mol, i))
.collect();
let mut scratch = Scratch::new(mol.num_atoms(), mol.num_bonds());
for system in crate::rings::fused_ring_systems(mol) {
if let Some(&d) = system
.iter()
.find(|&&a| mol.atoms()[a as usize].atomic_num == 0)
{
if system.iter().any(|&a| is_aromatic_atom(mol, a)) {
return Err(KekulizeError::UnsupportedAromaticDummy { atom: d });
}
}
kekulize_fused(mol, &system, &mut scratch)?;
}
scratch.debug_assert_clean();
mark_atoms_bonds(mol)?;
for i in 0..mol.num_atoms() as u32 {
let after = total_valence_nonstrict(mol, i);
if after != before[i as usize] {
return Err(KekulizeError::ValenceChanged {
atom: i,
before: before[i as usize],
after,
});
}
}
Ok(())
}
fn is_aromatic_atom(mol: &MolBuilder, idx: u32) -> bool {
if mol.atoms()[idx as usize]
.flags
.contains(AtomFlags::AROMATIC)
{
return true;
}
mol.neighbors(idx).any(|(_, bi)| {
let b = mol.bonds()[bi as usize];
b.flags.contains(BondFlags::AROMATIC) || b.order == BondOrder::Aromatic
})
}
fn bonds_of(mol: &MolBuilder, idx: u32) -> impl Iterator<Item = u32> + '_ {
mol.neighbors(idx).map(|(_, bi)| bi)
}
enum SearchOutcome {
Solved,
NoSolution,
BudgetExhausted { backtracks: usize },
}
struct Scratch {
cands: Vec<bool>,
in_all: Vec<bool>,
dbnd_adds: Vec<bool>,
local_added: Vec<bool>,
done_count: Vec<u32>,
astack_count: Vec<u32>,
}
impl Scratch {
fn new(n_atoms: usize, n_bonds: usize) -> Self {
Self {
cands: vec![false; n_atoms],
in_all: vec![false; n_atoms],
dbnd_adds: vec![false; n_bonds],
local_added: vec![false; n_bonds],
done_count: vec![0; n_atoms],
astack_count: vec![0; n_atoms],
}
}
fn clear(&mut self, mol: &MolBuilder, all_atms: &[u32]) {
for &a in all_atms {
let i = a as usize;
self.cands[i] = false;
self.in_all[i] = false;
self.done_count[i] = 0;
self.astack_count[i] = 0;
for (_, bi) in mol.neighbors(a) {
self.dbnd_adds[bi as usize] = false;
self.local_added[bi as usize] = false;
}
}
}
fn debug_assert_clean(&self) {
debug_assert!(self.cands.iter().all(|&x| !x), "cands 未清理干净");
debug_assert!(self.in_all.iter().all(|&x| !x), "in_all 未清理干净");
debug_assert!(self.dbnd_adds.iter().all(|&x| !x), "dbnd_adds 未清理干净");
debug_assert!(
self.local_added.iter().all(|&x| !x),
"local_added 未清理干净"
);
debug_assert!(
self.done_count.iter().all(|&c| c == 0),
"done_count 未清理干净"
);
debug_assert!(
self.astack_count.iter().all(|&c| c == 0),
"astack_count 未清理干净"
);
}
}
fn kekulize_fused(
mol: &mut MolBuilder,
all_atms: &[u32],
scratch: &mut Scratch,
) -> Result<(), KekulizeError> {
let done = mark_dbond_cands(mol, all_atms, scratch);
let outcome = kekulize_worker(mol, all_atms, done, scratch);
let result = match outcome {
SearchOutcome::Solved => Ok(()),
SearchOutcome::NoSolution => {
let mut atoms: Vec<u32> = all_atms
.iter()
.copied()
.filter(|&i| scratch.cands[i as usize])
.collect();
atoms.sort_unstable(); Err(KekulizeError::CannotKekulize { atoms })
}
SearchOutcome::BudgetExhausted { backtracks } => {
Err(KekulizeError::SearchBudgetExhausted {
system_size: all_atms.len(),
backtracks,
})
}
};
scratch.clear(mol, all_atms);
result
}
fn mark_dbond_cands(mol: &mut MolBuilder, all_atms: &[u32], scratch: &mut Scratch) -> Vec<u32> {
let mut done: Vec<u32> = Vec::new();
let has_aromatic = all_atms
.iter()
.any(|&a| mol.atoms()[a as usize].atomic_num == 0 || is_aromatic_atom(mol, a));
if !has_aromatic {
return done;
}
let mut make_single: Vec<u32> = Vec::new();
for &a in all_atms {
let atom = mol.atoms()[a as usize];
if atom.atomic_num != 0 && !is_aromatic_atom(mol, a) {
done.push(a);
continue;
}
let mut sbo: i32 = 0;
let mut n_to_ignore: i32 = 0;
for bi in bonds_of(mol, a) {
let b = mol.bonds()[bi as usize];
let aromatic_flagged = b.flags.contains(BondFlags::AROMATIC)
&& matches!(
b.order,
BondOrder::Single | BondOrder::Double | BondOrder::Aromatic
);
if aromatic_flagged {
sbo += 1;
make_single.push(bi);
} else {
let contrib = b.valence_contribution_to(a).round() as i32;
sbo += contrib;
if contrib == 0 {
n_to_ignore += 1;
}
}
}
let ev = explicit_valence_nonstrict(mol, a);
let implicit = i32::from(implicit_hs_nonstrict(mol, a, ev));
let total_hs = i32::from(atom.num_explicit_hs) + implicit;
sbo += total_hs;
let z = atom.atomic_num;
let valens = element::by_atomic_num(z).map_or(&[-1i8][..], |e| e.valences);
let mut dv = valens.first().map_or(-1, |&v| i32::from(v));
let mut chrg = i32::from(atom.formal_charge);
if element::is_early_atom(z) {
chrg = -chrg; }
if z == 6 && chrg > 0 {
chrg = -chrg; }
dv += chrg;
let tbo = ev + implicit;
let n_radicals = i32::from(atom.num_radical_electrons);
let degree = mol.degree(a) as i32;
let total_degree = degree + implicit - n_to_ignore;
let mut vi = 1usize;
while tbo > dv && vi < valens.len() && valens[vi] > 0 {
dv = i32::from(valens[vi]) + chrg;
vi += 1;
}
if tbo == 5
&& sbo == 4
&& dv == 3
&& total_degree == 3
&& n_radicals == 0
&& chrg == 0
&& total_hs == 0
&& matches!(z, 7 | 15 | 33)
{
dv = 5;
}
if total_degree + n_radicals >= dv {
continue;
}
let can_take_double = dv == sbo + 1 + n_radicals
|| (n_radicals == 0 && atom.flags.contains(AtomFlags::NO_IMPLICIT) && dv == sbo + 2);
if can_take_double {
scratch.cands[a as usize] = true;
}
}
for bi in make_single {
if let Some(mut b) = mol.bond_mut(bi) {
b.set_order(BondOrder::Single);
}
}
done
}
struct DoneList<'a> {
order: Vec<u32>,
count: &'a mut [u32],
}
impl<'a> DoneList<'a> {
fn new(count: &'a mut [u32], initial: Vec<u32>) -> Self {
for &a in &initial {
count[a as usize] += 1;
}
Self {
order: initial,
count,
}
}
fn push(&mut self, a: u32) {
self.order.push(a);
self.count[a as usize] += 1;
}
fn contains(&self, a: u32) -> bool {
self.count[a as usize] > 0
}
fn len(&self) -> usize {
self.order.len()
}
fn truncate(&mut self, keep: usize) {
for &a in &self.order[keep..] {
self.count[a as usize] -= 1;
}
self.order.truncate(keep);
}
}
struct AtomStack<'a> {
queue: VecDeque<u32>,
count: &'a mut [u32],
}
impl<'a> AtomStack<'a> {
fn new(count: &'a mut [u32]) -> Self {
Self {
queue: VecDeque::new(),
count,
}
}
fn pop_front(&mut self) -> Option<u32> {
let a = self.queue.pop_front()?;
self.count[a as usize] -= 1;
Some(a)
}
fn push_front(&mut self, a: u32) {
self.queue.push_front(a);
self.count[a as usize] += 1;
}
fn push_back(&mut self, a: u32) {
self.queue.push_back(a);
self.count[a as usize] += 1;
}
fn contains(&self, a: u32) -> bool {
self.count[a as usize] > 0
}
fn is_empty(&self) -> bool {
self.queue.is_empty()
}
}
fn kekulize_worker(
mol: &mut MolBuilder,
all_atms: &[u32],
done: Vec<u32>,
scratch: &mut Scratch,
) -> SearchOutcome {
let Scratch {
cands,
in_all,
dbnd_adds,
local_added,
done_count,
astack_count,
} = scratch;
let mut done = DoneList::new(done_count, done);
let mut astack = AtomStack::new(astack_count);
let mut options: HashMap<u32, VecDeque<u32>> = HashMap::new();
let mut btmoves: Vec<u32> = Vec::new();
let mut last_opt: Option<u32> = None;
let mut num_bt = 0usize;
let budget = backtrack_budget(all_atms.len());
for &a in all_atms {
in_all[a as usize] = true;
}
let mut sorted_atms = all_atms.to_vec();
sorted_atms.sort_unstable();
while done.len() < sorted_atms.len() || !astack.is_empty() {
let curr = match astack.pop_front() {
Some(c) => c,
None => match sorted_atms.iter().copied().find(|&a| !done.contains(a)) {
Some(c) => c,
None => break,
},
};
done.push(curr);
let c_cand = cands[curr as usize];
let mut opts: VecDeque<u32> = match options.get(&curr) {
Some(o) => o.clone(),
None => {
let mut nbrs: Vec<u32> = mol
.neighbors(curr)
.map(|(x, _)| x)
.filter(|&x| in_all[x as usize] && !done.contains(x))
.collect();
nbrs.sort_unstable();
nbrs.dedup();
let mut lstack: Vec<u32> = Vec::new();
let mut o: VecDeque<u32> = VecDeque::new();
for nbr in nbrs {
if !astack.contains(nbr) {
lstack.push(nbr);
}
if c_cand && cands[nbr as usize] {
let bi = mol.bond_between(curr, nbr).expect("邻居必有键");
if mol.bonds()[bi as usize].flags.contains(BondFlags::AROMATIC) {
o.push_back(nbr);
}
}
}
for a in lstack {
astack.push_back(a);
}
o
}
};
if !c_cand {
continue;
}
if let Some(ncnd) = opts.pop_front() {
let bi = mol.bond_between(curr, ncnd).expect("选项必有键");
if let Some(mut b) = mol.bond_mut(bi) {
b.set_order(BondOrder::Double);
b.set_direction(omgkit_core::BondDirection::None);
}
cands[curr as usize] = false;
cands[ncnd as usize] = false;
dbnd_adds[bi as usize] = true;
local_added[bi as usize] = true;
match options.entry(curr) {
Entry::Occupied(mut e) => {
if opts.is_empty() {
e.remove();
btmoves.pop();
last_opt = btmoves.last().copied();
} else {
e.insert(opts);
}
}
Entry::Vacant(e) => {
if !opts.is_empty() {
last_opt = Some(curr);
btmoves.push(curr);
e.insert(opts);
}
}
}
} else if mol.atoms()[curr as usize].atomic_num != 0 {
let undo = |mol: &mut MolBuilder| {
for (bi, &added) in local_added.iter().enumerate() {
if added {
if let Some(mut b) = mol.bond_mut(bi as u32) {
b.set_order(BondOrder::Single);
}
}
}
};
match last_opt {
Some(lo) if num_bt < budget => {
back_track(mol, lo, &mut done, &mut astack, cands, dbnd_adds);
num_bt += 1;
}
None => {
undo(mol);
return SearchOutcome::NoSolution;
}
Some(_) => {
undo(mol);
return SearchOutcome::BudgetExhausted { backtracks: num_bt };
}
}
}
}
SearchOutcome::Solved
}
fn back_track(
mol: &mut MolBuilder,
last_opt: u32,
done: &mut DoneList,
astack: &mut AtomStack,
cands: &mut [bool],
dbnd_adds: &mut [bool],
) {
let first = done.order.iter().position(|&x| x == last_opt).unwrap_or(0);
let last = done
.order
.iter()
.rposition(|&x| x == last_opt)
.unwrap_or(done.len().saturating_sub(1));
for &a in done.order[last..].iter().rev() {
astack.push_front(a);
}
done.truncate(first);
let to_undo: Vec<usize> = dbnd_adds
.iter()
.enumerate()
.filter(|&(_, &added)| added)
.map(|(bi, _)| bi)
.filter(|&bi| {
let b = mol.bonds()[bi];
!done.contains(b.begin) && !done.contains(b.end)
})
.collect();
for bi in to_undo {
let b = mol.bonds()[bi];
dbnd_adds[bi] = false;
if let Some(mut bm) = mol.bond_mut(bi as u32) {
bm.set_order(BondOrder::Single);
}
cands[b.begin as usize] = true;
cands[b.end as usize] = true;
}
}
fn mark_atoms_bonds(mol: &mut MolBuilder) -> Result<(), KekulizeError> {
for bi in 0..mol.num_bonds() as u32 {
if let Some(mut b) = mol.bond_mut(bi) {
b.flags_mut().remove(BondFlags::AROMATIC);
}
}
for i in 0..mol.num_atoms() as u32 {
let atom = mol.atoms()[i as usize];
if !atom.flags.contains(AtomFlags::AROMATIC) {
continue;
}
if !atom.flags.contains(AtomFlags::IN_RING) {
return Err(KekulizeError::NonRingAromaticAtom { atom: i });
}
let fix_pyrrole = matches!(atom.atomic_num, 7 | 15)
&& atom.formal_charge == 0
&& atom.num_explicit_hs == 1;
if let Some(a) = mol.atom_mut(i) {
a.flags.remove(AtomFlags::AROMATIC);
if fix_pyrrole {
a.flags.remove(AtomFlags::NO_IMPLICIT);
a.num_explicit_hs = 0;
}
}
if fix_pyrrole {
let ev = explicit_valence_nonstrict(mol, i);
let ih = implicit_hs_nonstrict(mol, i, ev);
if let Some(a) = mol.atom_mut(i) {
a.num_implicit_hs = ih;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use omgkit_core::BondFlags;
use omgkit_io::smiles;
use super::*;
use crate::{clean_up, perceive_rings, update_property_cache};
fn pipeline(smi: &str) -> Result<MolBuilder, KekulizeError> {
let mut m = smiles::parse(smi).unwrap_or_else(|e| panic!("{}", e.render()));
clean_up(&mut m);
update_property_cache(&mut m).expect("价键校验应通过");
let _ = perceive_rings(&mut m);
kekulize(&mut m)?;
Ok(m)
}
fn orders(smi: &str) -> Vec<BondOrder> {
pipeline(smi)
.unwrap()
.bonds()
.iter()
.map(|b| b.order)
.collect()
}
#[test]
fn independent_ring_systems_do_not_contaminate_each_other() {
for n in [1usize, 2, 3, 5, 8, 13, 30] {
let smi = vec!["c1ccccc1"; n].join("C");
let m = pipeline(&smi).unwrap_or_else(|e| panic!("{n} 个环: {e}"));
let doubles = m
.bonds()
.iter()
.filter(|b| b.order == BondOrder::Double)
.count();
assert_eq!(doubles, 3 * n, "{n} 个苯环应共有 {} 根双键", 3 * n);
for a in 0..m.num_atoms() as u32 {
let d = m
.neighbors(a)
.filter(|&(_, bi)| m.bonds()[bi as usize].order == BondOrder::Double)
.count();
let expect = usize::from(m.degree(a) >= 2 && m.atoms()[a as usize].atomic_num == 6);
let in_ring = m.atoms()[a as usize]
.flags
.contains(omgkit_core::AtomFlags::IN_RING);
let expect = if in_ring { expect } else { 0 };
assert_eq!(d, expect, "{n} 个环:原子 #{a} 的双键数不对");
}
}
}
#[test]
fn ring_system_result_is_independent_of_siblings() {
const RING: usize = 6;
let alone = orders("c1ccccc1");
assert_eq!(alone.len(), RING);
for n in [2usize, 4, 9] {
let smi = vec!["c1ccccc1"; n].join(".");
let m = pipeline(&smi).unwrap();
for k in 0..n {
let lo = (k * RING) as u32;
let hi = lo + RING as u32;
let ours: Vec<BondOrder> = m
.bonds()
.iter()
.filter(|b| (lo..hi).contains(&b.begin))
.map(|b| b.order)
.collect();
assert_eq!(
ours, alone,
"{n} 个片段:第 {k} 个环的结果与单独 kekulize 不同"
);
}
}
}
#[test]
fn insoluble_systems_are_proven_insoluble_not_abandoned() {
fn odd_fused(n: usize) -> String {
let rn = |k: usize| {
if k < 10 {
k.to_string()
} else {
format!("%{k}")
}
};
let mut s = String::from("c1ccc2");
for k in 2..n {
s.push_str(&format!("cc{}", rn(k + 1)));
}
s.push_str(&format!("ccccc{}", rn(n)));
for k in (2..n).rev() {
s.push_str(&format!("cc{}", rn(k)));
}
s.push_str("cc1");
s
}
for n in [2usize, 3, 4, 8, 16, 32] {
let smi = odd_fused(n);
let err = pipeline(&smi).expect_err(&format!("n={n}:该体系原子数为奇,不该能 kekulize"));
assert!(
matches!(err, KekulizeError::CannotKekulize { .. }),
"n={n}({smi}):应判定为确定无解,实际 {err}"
);
}
}
#[test]
fn no_aromatic_residue() {
for smi in [
"c1ccccc1",
"c1ccncc1",
"c1cc[nH]c1",
"c1ccc2ccccc2c1",
"CN1C=NC2=C1C(=O)N(C)C(=O)N2C",
"CC(=O)Oc1ccccc1C(=O)O",
"c1ccc2ccccc2c1.c1ccccc1",
] {
let m = pipeline(smi).unwrap();
assert!(
m.bonds()
.iter()
.all(|b| b.order != BondOrder::Aromatic
&& !b.flags.contains(BondFlags::AROMATIC)),
"{smi}: 仍有芳香键"
);
assert!(
m.atoms()
.iter()
.all(|a| !a.flags.contains(AtomFlags::AROMATIC)),
"{smi}: 仍有芳香原子"
);
}
}
#[test]
fn is_deterministic() {
for smi in [
"c1ccccc1",
"c1ccc2ccccc2c1",
"c1ccc2c(c1)ccc1ccccc12",
"CN1C=NC2=C1C(=O)N(C)C(=O)N2C",
] {
let first = orders(smi);
for _ in 0..5 {
assert_eq!(orders(smi), first, "{smi}: 结果不确定");
}
}
}
#[test]
fn total_valence_is_preserved() {
for smi in ["c1ccccc1", "c1cc[nH]c1", "c1ccncc1", "c1ccc2ccccc2c1"] {
let mut m = smiles::parse(smi).unwrap();
clean_up(&mut m);
update_property_cache(&mut m).unwrap();
let _ = perceive_rings(&mut m);
let before: Vec<i32> = (0..m.num_atoms() as u32)
.map(|i| total_valence_nonstrict(&m, i))
.collect();
kekulize(&mut m).unwrap();
let after: Vec<i32> = (0..m.num_atoms() as u32)
.map(|i| total_valence_nonstrict(&m, i))
.collect();
assert_eq!(before, after, "{smi}: 总价被改变");
}
}
#[test]
fn benzene_gets_three_double_bonds() {
let o = orders("c1ccccc1");
assert_eq!(o.iter().filter(|&&x| x == BondOrder::Double).count(), 3);
assert_eq!(o.iter().filter(|&&x| x == BondOrder::Single).count(), 3);
}
#[test]
fn naphthalene_gets_five_double_bonds() {
let o = orders("c1ccc2ccccc2c1");
assert_eq!(o.iter().filter(|&&x| x == BondOrder::Double).count(), 5);
}
#[test]
fn pyrrole_explicit_h_becomes_implicit() {
let m = pipeline("c1cc[nH]c1").unwrap();
let n = m
.atoms()
.iter()
.find(|a| a.atomic_num == 7)
.expect("应有氮");
assert_eq!(n.num_explicit_hs, 0, "显式氢应清零");
assert!(
!n.flags.contains(AtomFlags::NO_IMPLICIT),
"应恢复推断隐式氢"
);
assert_eq!(n.num_implicit_hs, 1, "氢应转为隐式");
}
#[test]
fn non_aromatic_molecule_is_untouched() {
let before = smiles::parse("CC(=O)O").unwrap();
let after = pipeline("CC(=O)O").unwrap();
assert_eq!(
before.bonds().iter().map(|b| b.order).collect::<Vec<_>>(),
after.bonds().iter().map(|b| b.order).collect::<Vec<_>>()
);
}
#[test]
fn aromatic_dummy_is_rejected_loudly() {
let mut m = smiles::parse("c1ccccc1").unwrap();
clean_up(&mut m);
update_property_cache(&mut m).unwrap();
let _ = perceive_rings(&mut m);
m.atom_mut(0).unwrap().atomic_num = 0;
assert!(
matches!(
kekulize(&mut m),
Err(KekulizeError::UnsupportedAromaticDummy { atom: 0 })
),
"芳香通配原子必须被显式拒绝"
);
}
#[test]
fn invalid_aromatic_claim_is_caught_only_by_kekulize() {
let mut m = smiles::parse("c1cncc1").unwrap();
clean_up(&mut m);
let v = update_property_cache(&mut m).expect("第 3 步不该失败");
let n_idx = m.atoms().iter().position(|a| a.atomic_num == 7).unwrap();
assert_eq!(v.implicit_hs[n_idx], 0, "无 H 的芳香氮应推得 0 个隐式氢");
let _ = perceive_rings(&mut m);
assert!(
matches!(kekulize(&mut m), Err(KekulizeError::CannotKekulize { .. })),
"非法芳香声称必须在 kekulize 处被拒"
);
}
#[test]
fn aromatic_heteroatom_hydrogens_are_inferred() {
for (smi, sym, want_h) in [
("c1cc[nH]c1", 7u8, 1u8), ("c1ccoc1", 8, 0), ("c1ccsc1", 16, 0), ("c1ccncc1", 7, 0), ] {
let mut m = smiles::parse(smi).unwrap();
clean_up(&mut m);
let v = update_property_cache(&mut m).unwrap_or_else(|e| panic!("{smi}: {e}"));
let i = m.atoms().iter().position(|a| a.atomic_num == sym).unwrap();
let total = v.implicit_hs[i] + m.atoms()[i].num_explicit_hs;
assert_eq!(total, want_h, "{smi}: 杂原子总氢数不对");
let _ = perceive_rings(&mut m);
assert!(kekulize(&mut m).is_ok(), "{smi}: 应能 kekulize");
}
}
#[test]
fn fusion_carbons_get_no_hydrogen() {
let mut m = smiles::parse("c1ccc2ccccc2c1").unwrap();
clean_up(&mut m);
let v = update_property_cache(&mut m).unwrap();
let with_h = v.implicit_hs.iter().filter(|&&h| h == 1).count();
let without_h = v.implicit_hs.iter().filter(|&&h| h == 0).count();
assert_eq!((with_h, without_h), (8, 2), "萘应是 8 个 CH + 2 个融合碳");
}
#[test]
fn non_aromatic_dummy_in_aromatic_ring_is_also_rejected() {
let mut m = smiles::parse("c1cc[*]cc1").unwrap();
clean_up(&mut m);
update_property_cache(&mut m).unwrap();
let _ = perceive_rings(&mut m);
assert!(
matches!(
kekulize(&mut m),
Err(KekulizeError::UnsupportedAromaticDummy { .. })
),
"应报'未实现',而不是错误的'无解'"
);
}
}