use crate::Error::MissingCell;
use crate::cage::Cage;
pub use crate::cage::CageOperator;
#[cfg(feature = "without-solution")]
use crate::cage::collinear_groups;
use crate::csp::{Constraint, generalized_arc_consistency};
use crate::fill::Fill;
use crate::grid::{AllDifferent, Grid as InternalGrid};
#[cfg(feature = "without-solution")]
use crate::mdd::CageDp;
#[cfg(feature = "without-solution")]
use crate::memo::Memo;
#[cfg(feature = "without-solution")]
use crate::operator::{CommutativeOperator, NonCommutativeOperator};
use crate::polyomino::{Cell, Polyomino};
#[cfg(feature = "without-solution")]
use crate::table::Table;
use crate::{Error, N, T};
use serde::de::Error as DeError;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::sync::Arc;
#[derive(Clone, Debug)]
pub struct Puzzle {
grid: InternalGrid,
cages: HashMap<Cell, Arc<Cage>>,
}
#[derive(Clone)]
enum PuzzleConstraint {
Cage(Arc<Cage>),
AllDifferent(AllDifferent),
}
impl std::fmt::Display for PuzzleConstraint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Cage(cage) => write!(f, "{cage}"),
Self::AllDifferent(ad) => write!(f, "{ad}"),
}
}
}
impl Constraint<InternalGrid, Cell, Fill, Error> for PuzzleConstraint {
fn propagate(&self, state: &InternalGrid) -> Result<(InternalGrid, Vec<Cell>), Error> {
match self {
Self::Cage(cage) => cage.propagate(state),
Self::AllDifferent(ad) => ad.propagate(state),
}
}
fn in_scope(&self, variable: Cell) -> bool {
match self {
Self::Cage(cage) => cage.in_scope(variable),
Self::AllDifferent(ad) => ad.in_scope(variable),
}
}
}
impl Puzzle {
pub fn new(n: usize) -> Result<Self, Error> {
Ok(Self {
grid: InternalGrid::new(n)?,
cages: HashMap::new(),
})
}
#[must_use]
pub const fn n(&self) -> usize {
self.grid.size()
}
#[must_use]
pub fn is_fully_covered(&self) -> bool {
self.cages.len() == self.n() * self.n()
}
pub fn cages(&self) -> impl Iterator<Item = &Cage> {
let mut seen: HashSet<*const Cage> = HashSet::new();
let mut cages: Vec<&Arc<Cage>> = self
.cages
.values()
.filter(move |arc| seen.insert(Arc::as_ptr(arc)))
.collect();
cages.sort_by_key(|arc| arc.polyomino.iter().copied().min());
cages.into_iter().map(Arc::as_ref)
}
pub fn solutions(&self) -> impl Iterator<Item = Result<Self, Error>> + '_ {
crate::solutions::Solutions::new(self)
}
pub fn cage_viable_counts(&self, polyomino: &Polyomino) -> Result<(u64, u64), Error> {
let cage_arc = polyomino
.iter()
.find_map(|cell| self.cages.get(cell))
.filter(|arc| &arc.polyomino == polyomino)
.ok_or_else(|| Error::MissingPolyomino(polyomino.clone()))?;
let fills: Vec<Fill> = cage_arc
.polyomino
.iter()
.map(|&cell| self.grid.get(cell))
.collect::<Result<_, _>>()?;
cage_arc.viable_counts(&fills)
}
#[must_use]
pub fn get_cage_at(&self, polyomino: &Polyomino) -> Option<&Cage> {
self.cages
.values()
.find(|arc| &arc.polyomino == polyomino)
.map(Arc::as_ref)
}
pub fn get(&self, cell: Cell) -> Result<Fill, Error> {
self.grid.get(cell)
}
#[allow(clippy::todo)]
pub fn set(&self, cell: Cell, n: N) -> Result<Self, Error> {
let fill = self.grid.get(cell)?;
if !fill.contains(n) {
return Err(Error::InvalidCellValue(cell, n));
}
Ok(Self {
grid: self.grid.set(cell, Fill::from(&[n])),
cages: self.cages.clone(),
})
}
fn check_in_bounds(&self, polyomino: &Polyomino) -> Result<(), Error> {
let n = self.grid.size();
if polyomino
.iter()
.any(|&Cell(r, c)| r < 1 || r > n || c < 1 || c > n)
{
Err(Error::MissingPolyomino(polyomino.clone()))
} else {
Ok(())
}
}
pub fn insert(
&self,
polyomino: &Polyomino,
operation: CageOperator,
target: T,
) -> Result<Option<Self>, Error> {
self.check_in_bounds(polyomino)?;
let n =
N::try_from(self.grid.size()).map_err(|_| Error::InvalidGridSize(self.grid.size()))?;
let mut seen: HashSet<*const Cage> = HashSet::new();
for arc in self.cages.values() {
if seen.insert(Arc::as_ptr(arc)) && !arc.polyomino.is_disjoint(polyomino) {
return Err(Error::CageConflict(polyomino.clone()));
}
}
let cage = Cage::new(n, polyomino.clone(), operation, target)?;
let mut cages = self.cages.clone();
let arc = Arc::new(cage);
for &cell in polyomino.iter() {
let _ = cages.insert(cell, Arc::clone(&arc));
}
Ok(Self {
grid: self.grid.clone(),
cages,
}
.fixpoint())
}
pub fn remove(&self, cage: &Cage) -> Result<Option<Self>, Error> {
let mut cages = self.cages.clone();
for cell in cage.polyomino.iter() {
let _ = cages.remove(cell).ok_or(MissingCell(*cell));
}
Ok(Self {
grid: self.grid.clone(),
cages,
}
.fixpoint())
}
#[cfg(feature = "without-solution")]
pub fn possible_operations(&self, polyomino: &Polyomino) -> Result<Vec<CageOperator>, Error> {
self.check_in_bounds(polyomino)?;
let n =
N::try_from(self.grid.size()).map_err(|_| Error::InvalidGridSize(self.grid.size()))?;
let fills: Vec<Fill> = polyomino
.iter()
.map(|&cell| self.grid.get(cell))
.collect::<Result<_, _>>()?;
let k = N::try_from(fills.len()).unwrap_or(N::MAX);
let candidates: &[CageOperator] = if k == 1 {
&[CageOperator::Given]
} else if k == 2 {
&[
CageOperator::Add,
CageOperator::Subtract,
CageOperator::Multiply,
CageOperator::Divide,
]
} else {
&[CageOperator::Add, CageOperator::Multiply]
};
let result = candidates
.iter()
.copied()
.filter(|&op| operator_is_feasible(self, polyomino, n, op, &fills))
.collect();
Ok(result)
}
#[cfg(feature = "without-solution")]
pub fn possible_targets(
&self,
polyomino: &Polyomino,
operation: CageOperator,
) -> Result<Vec<T>, Error> {
self.check_in_bounds(polyomino)?;
let n =
N::try_from(self.grid.size()).map_err(|_| Error::InvalidGridSize(self.grid.size()))?;
let fills: Vec<Fill> = polyomino
.iter()
.map(|&cell| self.grid.get(cell))
.collect::<Result<_, _>>()?;
let Some(range) = target_range(operation, &fills) else {
return Ok(vec![]);
};
let lines = collinear_groups(polyomino);
let result = range
.into_iter()
.filter(|&target| {
target_is_feasible(self, polyomino, n, operation, &fills, target, &lines)
})
.collect();
Ok(result)
}
#[must_use]
pub fn fixpoint(&self) -> Option<Self> {
let n = self.grid.size();
let mut seen: HashSet<*const Cage> = HashSet::new();
let unique_cages: Vec<Arc<Cage>> = self
.cages
.values()
.filter(|c| seen.insert(Arc::as_ptr(c)))
.map(Arc::clone)
.collect();
let mut constraints: Vec<PuzzleConstraint> = unique_cages
.iter()
.map(|c| PuzzleConstraint::Cage(Arc::clone(c)))
.collect();
for i in 1..=n {
constraints.push(PuzzleConstraint::AllDifferent(AllDifferent::row(n, i)));
constraints.push(PuzzleConstraint::AllDifferent(AllDifferent::column(n, i)));
}
let grid = generalized_arc_consistency(self.grid.clone(), &constraints)?;
Some(Self {
grid,
cages: self.cages.clone(),
})
}
}
fn cage_keys(p: &Puzzle) -> Vec<(Polyomino, CageOperator, T)> {
let mut seen = HashSet::new();
p.cages
.values()
.filter(|arc| seen.insert(Arc::as_ptr(arc)))
.map(|arc| {
let (op, target) = arc.op_target();
(arc.polyomino.clone(), op, target)
})
.collect()
}
impl PartialEq for Puzzle {
fn eq(&self, other: &Self) -> bool {
if self.n() != other.n() {
return false;
}
let mut a = cage_keys(self);
let mut b = cage_keys(other);
a.sort_unstable();
b.sort_unstable();
a == b
}
}
impl Eq for Puzzle {}
impl Hash for Puzzle {
fn hash<H: Hasher>(&self, state: &mut H) {
self.n().hash(state);
#[allow(clippy::collection_is_never_read)]
let mut keys = cage_keys(self);
keys.sort_unstable();
keys.hash(state);
}
}
impl std::fmt::Display for Puzzle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let n = self.n();
let count = self.cages().count();
write!(f, "{n}×{n} puzzle, {count} cages")
}
}
#[derive(Serialize, Deserialize)]
struct CageWire {
polyomino: Vec<Cell>,
operation: CageOperator,
target: T,
}
#[derive(Serialize, Deserialize)]
struct PuzzleWire {
n: usize,
#[serde(default)]
cages: Vec<CageWire>,
}
impl Serialize for Puzzle {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let n = self.n();
let mut cages: Vec<CageWire> = {
let mut seen: HashSet<*const Cage> = HashSet::new();
self.cages
.values()
.filter(|arc| seen.insert(Arc::as_ptr(arc)))
.map(|arc| {
let (operation, target) = cage_op_target(arc);
CageWire {
polyomino: arc.polyomino.iter().copied().collect(),
operation,
target,
}
})
.collect()
};
cages.sort_by_key(|c| c.polyomino.iter().copied().min());
PuzzleWire { n, cages }.serialize(s)
}
}
fn cage_op_target(cage: &Cage) -> (CageOperator, T) {
cage.op_target()
}
impl<'de> Deserialize<'de> for Puzzle {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let wire = PuzzleWire::deserialize(d)?;
let mut puzzle = Self::new(wire.n).map_err(|e| DeError::custom(e.to_string()))?;
for cage_wire in wire.cages {
let polyomino =
Polyomino::from(cage_wire.polyomino).map_err(|e| DeError::custom(e.to_string()))?;
puzzle = puzzle
.insert(&polyomino, cage_wire.operation, cage_wire.target)
.map_err(|e| DeError::custom(e.to_string()))?
.ok_or_else(|| DeError::custom("infeasible cage"))?;
}
Ok(puzzle)
}
}
impl Puzzle {
pub fn from_latin_square(n: usize, square: &[Vec<N>]) -> Result<Self, Error> {
let mut puzzle = Self::new(n)?;
for (r, row) in square.iter().enumerate() {
for (c, &v) in row.iter().enumerate() {
let cell = Cell::new(r, c);
let poly = Polyomino::from([cell])?;
puzzle = puzzle
.insert(&poly, CageOperator::Given, T::from(v))?
.ok_or(Error::EmptyFills)?;
}
}
Ok(puzzle)
}
pub fn insert_cage(&self, cage: &Cage) -> Result<Option<Self>, Error> {
let (op, target) = cage.op_target();
self.insert(&cage.polyomino, op, target)
}
pub fn remove_cage(&self, cage: &Cage) -> Result<Option<Self>, Error> {
self.remove(cage)
}
}
#[must_use]
pub fn operators_for(polyomino: &Polyomino) -> Vec<CageOperator> {
match polyomino.len() {
0 => vec![],
1 => vec![CageOperator::Given],
2 => vec![
CageOperator::Add,
CageOperator::Subtract,
CageOperator::Multiply,
CageOperator::Divide,
],
_ => vec![CageOperator::Add, CageOperator::Multiply],
}
}
#[cfg(feature = "without-solution")]
fn target_range(op: CageOperator, fills: &[Fill]) -> Option<std::ops::RangeInclusive<T>> {
let mins: Option<Vec<T>> = fills.iter().map(|f| f.min_value().map(T::from)).collect();
let maxs: Option<Vec<T>> = fills.iter().map(|f| f.max_value().map(T::from)).collect();
let mins = mins?;
let maxs = maxs?;
match op {
CageOperator::Given => Some(mins[0]..=maxs[0]),
CageOperator::Add => Some(mins.iter().sum()..=maxs.iter().sum()),
CageOperator::Multiply => Some(mins.iter().product()..=maxs.iter().product()),
CageOperator::Subtract => {
let max_val = maxs[0].max(maxs[1]);
let min_val = mins[0].min(mins[1]);
let hi = max_val - min_val;
if hi == 0 { None } else { Some(1..=hi) }
}
CageOperator::Divide => {
let max_val = maxs[0].max(maxs[1]);
let min_val = mins[0].min(mins[1]);
let hi = max_val / min_val;
if hi < 2 { None } else { Some(2..=hi) }
}
}
}
#[cfg(feature = "without-solution")]
fn operator_is_feasible(
puzzle: &Puzzle,
polyomino: &Polyomino,
n: N,
op: CageOperator,
fills: &[Fill],
) -> bool {
let Some(range) = target_range(op, fills) else {
return false;
};
let lines = collinear_groups(polyomino);
range
.into_iter()
.any(|target| target_is_feasible(puzzle, polyomino, n, op, fills, target, &lines))
}
#[cfg(feature = "without-solution")]
fn target_is_feasible(
puzzle: &Puzzle,
polyomino: &Polyomino,
n: N,
op: CageOperator,
fills: &[Fill],
target: T,
lines: &[Vec<usize>],
) -> bool {
let k = N::try_from(fills.len()).unwrap_or(N::MAX);
let has_consistent_tuple = match op {
CageOperator::Given => N::try_from(target).is_ok_and(|v| fills[0].contains(v)),
CageOperator::Add => CageDp::new(n, k, CommutativeOperator::Add, target, lines)
.solutions(fills)
.next()
.is_some(),
CageOperator::Multiply => CageDp::new(n, k, CommutativeOperator::Multiply, target, lines)
.solutions(fills)
.next()
.is_some(),
CageOperator::Subtract => {
Table::non_commutative(n, NonCommutativeOperator::Subtract, target)
.is_ok_and(|t| t.narrow(fills).is_ok())
}
CageOperator::Divide => Table::non_commutative(n, NonCommutativeOperator::Divide, target)
.is_ok_and(|t| t.narrow(fills).is_ok()),
};
has_consistent_tuple
&& puzzle
.insert(polyomino, op, target)
.ok()
.flatten()
.is_some()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::operator::CommutativeOperator::Add;
use crate::operator::NonCommutativeOperator::Subtract;
use crate::polyomino::Polyomino;
#[test]
fn add_cage_arm_cells_exclude_values_requiring_collinear_duplicates() {
crate::init_debug_logging();
let p = Puzzle::new(4).unwrap();
let poly = Polyomino::from([Cell(1, 1), Cell(1, 2), Cell(2, 1)]).unwrap();
let p = p.insert(&poly, CageOperator::Add, 6).unwrap().unwrap();
let corner = p.get(Cell(1, 1)).unwrap();
let arm1 = p.get(Cell(1, 2)).unwrap();
let arm2 = p.get(Cell(2, 1)).unwrap();
assert!(
corner.contains(4),
"corner (1,1) should admit 4 via tuple (4,1,1); got {corner}"
);
assert!(
!arm1.contains(4),
"arm (1,2) cannot be 4: forces two collinear 1s at (1,1) and (2,1); got {arm1}"
);
assert!(
!arm2.contains(4),
"arm (2,1) cannot be 4: forces two collinear 1s at (1,1) and (1,2); got {arm2}"
);
}
#[test]
fn cage_viable_counts_add_domino_respects_arithmetic() {
let p = Puzzle::new(4).unwrap();
let poly = domino(1, 1, 1, 2);
let p = p.insert(&poly, CageOperator::Add, 5).unwrap().unwrap();
assert_eq!(p.cage_viable_counts(&poly).unwrap(), (2, 4));
}
#[test]
fn cage_viable_counts_l_cage_matches_mdd() {
let p = Puzzle::new(4).unwrap();
let poly = Polyomino::from([Cell(1, 1), Cell(1, 2), Cell(2, 1)]).unwrap();
let p = p.insert(&poly, CageOperator::Add, 6).unwrap().unwrap();
assert_eq!(p.cage_viable_counts(&poly).unwrap(), (2, 7));
}
#[test]
fn cage_viable_counts_narrows_with_pinned_cell() {
let p = Puzzle::new(4).unwrap();
let poly = domino(1, 1, 1, 2);
let p = p.insert(&poly, CageOperator::Add, 5).unwrap().unwrap();
let p = p.set(Cell(1, 1), 4).unwrap();
assert_eq!(p.cage_viable_counts(&poly).unwrap(), (1, 1));
}
#[test]
fn cage_viable_counts_subtract_counts_table_pairs() {
let p = Puzzle::new(4).unwrap();
let poly = domino(1, 1, 1, 2);
let p = p.insert(&poly, CageOperator::Subtract, 1).unwrap().unwrap();
assert_eq!(p.cage_viable_counts(&poly).unwrap(), (3, 6));
}
#[test]
fn cage_viable_counts_given_is_single_tuple() {
let p = Puzzle::new(4).unwrap();
let poly = Polyomino::from([Cell(1, 1)]).unwrap();
let p = p.insert(&poly, CageOperator::Given, 3).unwrap().unwrap();
assert_eq!(p.cage_viable_counts(&poly).unwrap(), (1, 1));
}
#[test]
fn cage_viable_counts_missing_cage_returns_error() {
let p = Puzzle::new(4).unwrap();
assert!(matches!(
p.cage_viable_counts(&domino(1, 1, 1, 2)),
Err(Error::MissingPolyomino(_))
));
}
#[test]
fn perf_cage_viable_counts_fat_cage_in_9x9() {
let cells: Vec<Cell> = (1..=2)
.flat_map(|r| (1..=4).map(move |c| Cell(r, c)))
.collect();
let poly = Polyomino::from(cells).unwrap();
let p = Puzzle::new(9).unwrap();
let p = p.insert(&poly, CageOperator::Add, 40).unwrap().unwrap();
let (multisets, tuples) = p.cage_viable_counts(&poly).unwrap();
assert!(multisets >= 1);
assert!(tuples >= multisets);
}
#[cfg(feature = "without-solution")]
#[test]
fn possible_targets_given_singleton() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = Polyomino::from([Cell(1, 1)]).unwrap();
let targets = p.possible_targets(&poly, CageOperator::Given).unwrap();
assert_eq!(targets, vec![1, 2, 3, 4]);
}
#[cfg(feature = "without-solution")]
#[test]
fn possible_targets_add_domino() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = domino(1, 1, 1, 2);
let targets = p.possible_targets(&poly, CageOperator::Add).unwrap();
assert!(targets.contains(&3));
assert!(targets.contains(&5));
assert!(targets.contains(&7));
assert!(!targets.contains(&1));
assert!(!targets.contains(&2));
assert!(!targets.contains(&8));
}
#[cfg(feature = "without-solution")]
#[test]
fn possible_targets_subtract_excludes_zero() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = domino(1, 1, 1, 2);
let targets = p.possible_targets(&poly, CageOperator::Subtract).unwrap();
assert!(!targets.is_empty());
assert!(!targets.contains(&0));
}
#[cfg(feature = "without-solution")]
#[test]
fn possible_targets_divide_starts_at_two() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = domino(1, 1, 1, 2);
let targets = p.possible_targets(&poly, CageOperator::Divide).unwrap();
assert!(!targets.is_empty());
assert!(!targets.contains(&1));
assert!(targets.iter().all(|&t| t >= 2));
}
#[cfg(feature = "without-solution")]
#[test]
fn possible_targets_multiply_domino() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = domino(1, 1, 1, 2);
let targets = p.possible_targets(&poly, CageOperator::Multiply).unwrap();
assert!(targets.contains(&2));
assert!(targets.contains(&6));
assert!(targets.contains(&12));
assert!(!targets.contains(&1));
}
#[cfg(feature = "without-solution")]
#[test]
fn possible_targets_narrows_with_constrained_cell() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = domino(1, 1, 1, 2);
let all_targets = p.possible_targets(&poly, CageOperator::Add).unwrap();
let p_pinned = p.set(Cell(1, 1), 1).unwrap();
let pinned_targets = p_pinned.possible_targets(&poly, CageOperator::Add).unwrap();
assert!(pinned_targets.len() < all_targets.len());
assert!(!pinned_targets.contains(&7)); }
#[cfg(feature = "without-solution")]
#[test]
fn possible_targets_missing_cell_error() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = Polyomino::from([Cell(5, 1)]).unwrap();
assert!(p.possible_targets(&poly, CageOperator::Given).is_err());
}
impl Puzzle {
fn from_parts(grid: InternalGrid, cage_list: Vec<Cage>) -> Self {
let mut cages: HashMap<Cell, Arc<Cage>> = HashMap::new();
for cage in cage_list {
let arc = Arc::new(cage);
for &cell in arc.polyomino.iter() {
let _ = cages.insert(cell, Arc::clone(&arc));
}
}
Self { grid, cages }
}
}
fn domino(r0: usize, c0: usize, r1: usize, c1: usize) -> Polyomino {
Polyomino::from([Cell(r0, c0), Cell(r1, c1)]).unwrap()
}
#[cfg(feature = "without-solution")]
#[test]
fn possible_operations_size_10_returns_only_commutative() {
let mut cells: Vec<Cell> = (1..=5).map(|r| Cell(r, 1)).collect();
cells.extend((5..=9).map(|r| Cell(r, 2)));
let poly = Polyomino::from(cells).unwrap();
let p = Puzzle::from_parts(InternalGrid::new(9).unwrap(), vec![]);
let ops = p.possible_operations(&poly).unwrap();
assert!(ops.iter().any(|o| matches!(o, CageOperator::Add)));
assert!(ops.iter().any(|o| matches!(o, CageOperator::Multiply)));
assert!(!ops.iter().any(|o| matches!(o, CageOperator::Subtract)));
assert!(!ops.iter().any(|o| matches!(o, CageOperator::Divide)));
assert!(!ops.iter().any(|o| matches!(o, CageOperator::Given)));
}
#[cfg(feature = "without-solution")]
#[test]
fn possible_operations_singleton_returns_only_given() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = Polyomino::from([Cell(1, 1)]).unwrap();
let ops = p.possible_operations(&poly).unwrap();
assert_eq!(ops.len(), 1);
assert!(matches!(ops[0], CageOperator::Given));
}
#[cfg(feature = "without-solution")]
#[test]
fn possible_operations_domino_includes_all_four() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = domino(1, 1, 1, 2);
let ops = p.possible_operations(&poly).unwrap();
assert!(ops.iter().any(|o| matches!(o, CageOperator::Add)));
assert!(ops.iter().any(|o| matches!(o, CageOperator::Subtract)));
assert!(ops.iter().any(|o| matches!(o, CageOperator::Multiply)));
assert!(ops.iter().any(|o| matches!(o, CageOperator::Divide)));
}
#[test]
fn possible_operations_divide_never_produces_target_one() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = domino(1, 1, 1, 2);
assert!(
p.insert(&poly, CageOperator::Divide, 1).is_err(),
"Divide target 1 must be rejected as infeasible"
);
}
#[cfg(feature = "without-solution")]
#[test]
fn possible_operations_triomino_excludes_non_commutative() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = Polyomino::from([Cell(1, 1), Cell(1, 2), Cell(1, 3)]).unwrap();
let ops = p.possible_operations(&poly).unwrap();
assert!(ops.iter().any(|o| matches!(o, CageOperator::Add)));
assert!(ops.iter().any(|o| matches!(o, CageOperator::Multiply)));
assert!(!ops.iter().any(|o| matches!(o, CageOperator::Subtract)));
assert!(!ops.iter().any(|o| matches!(o, CageOperator::Divide)));
}
#[cfg(feature = "without-solution")]
#[test]
fn possible_operations_returns_error_for_out_of_grid_cell() {
let p = Puzzle::from_parts(InternalGrid::new(2).unwrap(), vec![]);
let poly = Polyomino::from([Cell(9, 9)]).unwrap();
assert!(matches!(
p.possible_operations(&poly),
Err(Error::MissingPolyomino(_))
));
}
#[cfg(feature = "without-solution")]
#[test]
fn possible_operations_given_only_returns_values_in_fill() {
let c1 = Cage::given(Cell(1, 1), 3).unwrap();
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![c1])
.fixpoint()
.unwrap();
let poly = Polyomino::from([Cell(1, 2)]).unwrap();
let ops = p.possible_operations(&poly).unwrap();
assert!(ops.iter().any(|o| matches!(o, CageOperator::Given)));
}
#[cfg(feature = "without-solution")]
#[test]
fn possible_operations_given_not_feasible_when_fill_empty() {
let c1 = Cage::given(Cell(1, 1), 1).unwrap();
let c2 = Cage::given(Cell(1, 2), 2).unwrap();
let p = Puzzle::from_parts(InternalGrid::new(2).unwrap(), vec![c1, c2])
.fixpoint()
.unwrap();
let poly = Polyomino::from([Cell(2, 1)]).unwrap();
let ops = p.possible_operations(&poly).unwrap();
assert!(ops.iter().any(|o| matches!(o, CageOperator::Given)));
}
#[cfg(feature = "without-solution")]
#[test]
fn possible_operations_subtract_excluded_in_2x2_with_only_one_unit_pair() {
let c1 = Cage::given(Cell(1, 1), 1).unwrap();
let p = Puzzle::from_parts(InternalGrid::new(2).unwrap(), vec![c1])
.fixpoint()
.unwrap();
let poly = Polyomino::from([Cell(2, 2)]).unwrap();
let ops = p.possible_operations(&poly).unwrap();
assert_eq!(ops.len(), 1);
assert!(matches!(ops[0], CageOperator::Given));
}
#[cfg(feature = "without-solution")]
#[test]
fn possible_operations_fixpoint_check_excludes_infeasible_operator() {
let c1 = Cage::given(Cell(2, 1), 1).unwrap();
let c2 = Cage::given(Cell(2, 2), 2).unwrap();
let p = Puzzle::from_parts(InternalGrid::new(2).unwrap(), vec![c1, c2])
.fixpoint()
.unwrap();
assert_eq!(p.get(Cell(1, 1)).unwrap(), Fill::from(&[2]));
assert_eq!(p.get(Cell(1, 2)).unwrap(), Fill::from(&[1]));
let poly = Polyomino::from([Cell(1, 1)]).unwrap();
let ops = p.possible_operations(&poly).unwrap();
assert_eq!(ops.len(), 1);
assert!(matches!(ops[0], CageOperator::Given));
assert!(p.insert(&poly, CageOperator::Given, 2).unwrap().is_some());
}
#[test]
fn insert_cage_pins_cell() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = Polyomino::from([Cell(1, 1)]).unwrap();
let fp = p.insert(&poly, CageOperator::Given, 3).unwrap().unwrap();
assert_eq!(fp.get(Cell(1, 1)).unwrap(), Fill::from(&[3]));
}
#[test]
fn insert_missing_polyomino_returns_error() {
let p = Puzzle::from_parts(InternalGrid::new(2).unwrap(), vec![]);
let poly = Polyomino::from([Cell(9, 9)]).unwrap();
assert!(matches!(
p.insert(&poly, CageOperator::Given, 1),
Err(Error::MissingPolyomino(_))
));
}
#[test]
fn insert_overlapping_cage_returns_error() {
let cage = Cage::given(Cell(1, 1), 1).unwrap();
let p = Puzzle::from_parts(InternalGrid::new(2).unwrap(), vec![cage]);
let poly = Polyomino::from([Cell(1, 1)]).unwrap();
assert!(matches!(
p.insert(&poly, CageOperator::Given, 2),
Err(Error::CageConflict(_))
));
}
#[test]
fn insert_infeasible_cage_returns_none() {
let p = Puzzle::from_parts(InternalGrid::new(2).unwrap(), vec![]);
let p = p
.insert(
&Polyomino::from([Cell(1, 1)]).unwrap(),
CageOperator::Given,
1,
)
.unwrap()
.unwrap();
let poly = Polyomino::from([Cell(1, 2)]).unwrap();
assert!(p.insert(&poly, CageOperator::Given, 1).unwrap().is_none());
}
#[test]
fn insert_add_cage_prunes_cells() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = domino(1, 1, 1, 2);
let fp = p.insert(&poly, CageOperator::Add, 3).unwrap().unwrap();
assert_eq!(fp.get(Cell(1, 1)).unwrap(), Fill::from(&[1, 2]));
assert_eq!(fp.get(Cell(1, 2)).unwrap(), Fill::from(&[1, 2]));
}
#[test]
fn insert_multiply_cage_prunes_cells() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = domino(1, 1, 1, 2);
let fp = p.insert(&poly, CageOperator::Multiply, 6).unwrap().unwrap();
assert_eq!(fp.get(Cell(1, 1)).unwrap(), Fill::from(&[2, 3]));
assert_eq!(fp.get(Cell(1, 2)).unwrap(), Fill::from(&[2, 3]));
}
#[test]
fn insert_subtract_cage_prunes_cells() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = domino(1, 1, 1, 2);
let fp = p.insert(&poly, CageOperator::Subtract, 3).unwrap().unwrap();
assert_eq!(fp.get(Cell(1, 1)).unwrap(), Fill::from(&[1, 4]));
assert_eq!(fp.get(Cell(1, 2)).unwrap(), Fill::from(&[1, 4]));
}
#[test]
fn insert_divide_cage_prunes_cells() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = domino(1, 1, 1, 2);
let fp = p.insert(&poly, CageOperator::Divide, 4).unwrap().unwrap();
assert_eq!(fp.get(Cell(1, 1)).unwrap(), Fill::from(&[1, 4]));
assert_eq!(fp.get(Cell(1, 2)).unwrap(), Fill::from(&[1, 4]));
}
#[test]
fn insert_does_not_affect_unrelated_cells() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = Polyomino::from([Cell(1, 1)]).unwrap();
let fp = p.insert(&poly, CageOperator::Given, 3).unwrap().unwrap();
assert_eq!(fp.get(Cell(2, 2)).unwrap(), Fill::all(4));
}
#[test]
fn insert_cell_at_boundary_succeeds() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = Polyomino::from([Cell(4, 4)]).unwrap();
assert!(p.insert(&poly, CageOperator::Given, 4).unwrap().is_some());
}
#[test]
fn insert_cell_row_zero_returns_missing_polyomino() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = Polyomino::from([Cell(0, 1)]).unwrap();
assert!(matches!(
p.insert(&poly, CageOperator::Given, 1),
Err(Error::MissingPolyomino(_))
));
}
#[test]
fn insert_cell_col_zero_returns_missing_polyomino() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let poly = Polyomino::from([Cell(1, 0)]).unwrap();
assert!(matches!(
p.insert(&poly, CageOperator::Given, 1),
Err(Error::MissingPolyomino(_))
));
}
#[test]
fn get_returns_full_fill_for_unconstrained_cell() {
let p = Puzzle::from_parts(InternalGrid::new(3).unwrap(), vec![]);
assert_eq!(p.get(Cell(2, 2)).unwrap(), Fill::all(3));
}
#[test]
fn get_missing_cell_returns_error() {
let p = Puzzle::from_parts(InternalGrid::new(3).unwrap(), vec![]);
assert!(matches!(p.get(Cell(9, 9)), Err(MissingCell(_))));
}
#[test]
fn set_pins_cell_to_value() {
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![]);
let p2 = p.set(Cell(1, 1), 3).unwrap();
assert_eq!(p2.get(Cell(1, 1)).unwrap(), Fill::from(&[3]));
}
#[test]
fn set_invalid_value_returns_error() {
let cage = Cage::given(Cell(1, 1), 2).unwrap();
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![cage]);
let p = p.fixpoint().unwrap();
assert!(matches!(
p.set(Cell(1, 1), 3),
Err(Error::InvalidCellValue(_, 3))
));
}
#[test]
fn fixpoint_no_cages_full_grid_unchanged() {
let p = Puzzle::from_parts(InternalGrid::new(2).unwrap(), vec![]);
let fp = p.fixpoint().unwrap();
assert_eq!(fp.get(Cell(1, 1)).unwrap(), Fill::all(2));
assert_eq!(fp.get(Cell(1, 2)).unwrap(), Fill::all(2));
}
#[test]
fn fixpoint_given_cage_pins_cell() {
let cage = Cage::given(Cell(1, 1), 3).unwrap();
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![cage]);
let fp = p.fixpoint().unwrap();
assert_eq!(fp.get(Cell(1, 1)).unwrap(), Fill::from(&[3]));
}
#[test]
fn fixpoint_given_cage_propagates_through_all_different() {
let cage = Cage::given(Cell(1, 1), 2).unwrap();
let p = Puzzle::from_parts(InternalGrid::new(3).unwrap(), vec![cage]);
let fp = p.fixpoint().unwrap();
assert_eq!(fp.get(Cell(1, 1)).unwrap(), Fill::from(&[2]));
assert!(!fp.get(Cell(1, 2)).unwrap().contains(2));
assert!(!fp.get(Cell(1, 3)).unwrap().contains(2));
assert!(!fp.get(Cell(2, 1)).unwrap().contains(2));
assert!(!fp.get(Cell(3, 1)).unwrap().contains(2));
}
#[test]
fn fixpoint_add_cage_prunes_both_cells() {
let cage = Cage::commutative(4, domino(1, 1, 1, 2), Add, 3).unwrap();
let p = Puzzle::from_parts(InternalGrid::new(4).unwrap(), vec![cage]);
let fp = p.fixpoint().unwrap();
assert_eq!(fp.get(Cell(1, 1)).unwrap(), Fill::from(&[1, 2]));
assert_eq!(fp.get(Cell(1, 2)).unwrap(), Fill::from(&[1, 2]));
}
#[test]
fn fixpoint_cage_and_all_different_chain() {
let cage = Cage::non_commutative(2, domino(1, 1, 2, 1), Subtract, 1).unwrap();
let p = Puzzle::from_parts(InternalGrid::new(2).unwrap(), vec![cage]);
assert!(p.fixpoint().is_some());
}
#[test]
fn is_fully_covered_empty_puzzle_is_false() {
let p = Puzzle::new(2).unwrap();
assert!(!p.is_fully_covered());
}
#[test]
fn is_fully_covered_partial_coverage_is_false() {
let p = Puzzle::new(2)
.unwrap()
.insert(
&Polyomino::from([Cell(1, 1)]).unwrap(),
CageOperator::Given,
1,
)
.unwrap()
.unwrap();
assert!(!p.is_fully_covered());
}
#[test]
fn is_fully_covered_full_coverage_is_true() {
let square: Vec<Vec<N>> = vec![vec![1, 2], vec![2, 1]];
let p = Puzzle::from_latin_square(2, &square).unwrap();
assert!(p.is_fully_covered());
}
#[test]
fn fixpoint_infeasible_returns_none() {
let c1 = Cage::given(Cell(1, 1), 1).unwrap();
let c2 = Cage::given(Cell(1, 2), 1).unwrap();
let p = Puzzle::from_parts(InternalGrid::new(2).unwrap(), vec![c1, c2]);
assert!(p.fixpoint().is_none());
}
fn two_cage_puzzle() -> (Puzzle, Polyomino, Polyomino) {
let add = domino(1, 1, 1, 2);
let given = Polyomino::from([Cell(2, 1)]).unwrap();
let p = Puzzle::new(4)
.unwrap()
.insert(&add, CageOperator::Add, 5)
.unwrap()
.unwrap()
.insert(&given, CageOperator::Given, 3)
.unwrap()
.unwrap();
(p, add, given)
}
#[test]
fn cages_yields_unique_cages_sorted_by_anchor() {
let (p, add, given) = two_cage_puzzle();
let polys: Vec<Polyomino> = p.cages().map(|c| c.polyomino.clone()).collect();
assert_eq!(polys, vec![add, given]);
}
#[test]
fn get_cage_at_exact_polyomino_returns_cage() {
let (p, add, _) = two_cage_puzzle();
let cage = p.get_cage_at(&add).unwrap();
assert_eq!(cage.polyomino, add);
}
#[test]
fn get_cage_at_unknown_polyomino_returns_none() {
let (p, _, _) = two_cage_puzzle();
assert!(p.get_cage_at(&domino(3, 3, 3, 4)).is_none());
}
#[test]
fn remove_drops_cage_but_keeps_grid_state() {
let (p, add, _) = two_cage_puzzle();
let cage = p.get_cage_at(&add).unwrap().clone();
let removed = p.remove(&cage).unwrap().unwrap();
assert!(removed.get_cage_at(&add).is_none());
assert_eq!(removed.get(Cell(1, 2)).unwrap(), p.get(Cell(1, 2)).unwrap());
}
#[test]
fn remove_cage_alias_matches_remove() {
let (p, add, _) = two_cage_puzzle();
let cage = p.get_cage_at(&add).unwrap().clone();
let removed = p.remove_cage(&cage).unwrap().unwrap();
assert!(removed.get_cage_at(&add).is_none());
}
#[test]
fn insert_cage_alias_matches_insert() {
let p = Puzzle::new(4).unwrap();
let cage = Cage::new(4, domino(1, 1, 1, 2), CageOperator::Add, 5).unwrap();
let inserted = p.insert_cage(&cage).unwrap().unwrap();
assert!(inserted.get_cage_at(&domino(1, 1, 1, 2)).is_some());
}
#[test]
fn eq_ignores_insertion_order() {
let add = domino(1, 1, 1, 2);
let given = Polyomino::from([Cell(2, 1)]).unwrap();
let a = Puzzle::new(4)
.unwrap()
.insert(&add, CageOperator::Add, 5)
.unwrap()
.unwrap()
.insert(&given, CageOperator::Given, 3)
.unwrap()
.unwrap();
let b = Puzzle::new(4)
.unwrap()
.insert(&given, CageOperator::Given, 3)
.unwrap()
.unwrap()
.insert(&add, CageOperator::Add, 5)
.unwrap()
.unwrap();
assert_eq!(a, b);
}
#[test]
fn eq_distinguishes_grid_size() {
assert_ne!(Puzzle::new(3).unwrap(), Puzzle::new(4).unwrap());
}
#[test]
fn eq_distinguishes_cage_targets() {
let poly = domino(1, 1, 1, 2);
let a = Puzzle::new(4)
.unwrap()
.insert(&poly, CageOperator::Add, 5)
.unwrap()
.unwrap();
let b = Puzzle::new(4)
.unwrap()
.insert(&poly, CageOperator::Add, 6)
.unwrap()
.unwrap();
assert_ne!(a, b);
}
#[test]
fn hash_equal_for_equal_puzzles() {
use std::collections::hash_map::DefaultHasher;
let hash = |p: &Puzzle| {
let mut h = DefaultHasher::new();
p.hash(&mut h);
h.finish()
};
let (a, _, _) = two_cage_puzzle();
let (b, _, _) = two_cage_puzzle();
assert_eq!(hash(&a), hash(&b));
}
#[test]
fn display_reports_size_and_cage_count() {
let (p, _, _) = two_cage_puzzle();
assert_eq!(p.to_string(), "4×4 puzzle, 2 cages");
}
#[test]
fn serde_round_trip_preserves_puzzle() {
let (p, _, _) = two_cage_puzzle();
let json = serde_json::to_string(&p).unwrap();
let back: Puzzle = serde_json::from_str(&json).unwrap();
assert_eq!(p, back);
}
#[test]
fn serialize_orders_cages_by_anchor() {
let (p, _, _) = two_cage_puzzle();
let v: serde_json::Value = serde_json::to_value(&p).unwrap();
assert_eq!(v["n"], 4);
let cages = v["cages"].as_array().unwrap();
assert_eq!(cages.len(), 2);
assert_eq!(cages[0]["operation"], "Add");
assert_eq!(cages[1]["operation"], "Given");
}
#[test]
fn deserialize_invalid_grid_size_errors() {
assert!(serde_json::from_str::<Puzzle>(r#"{"n":0,"cages":[]}"#).is_err());
}
#[test]
fn deserialize_disconnected_polyomino_errors() {
let json = r#"{"n":4,"cages":[{"polyomino":[[1,1],[3,3]],"operation":"Add","target":5}]}"#;
assert!(serde_json::from_str::<Puzzle>(json).is_err());
}
#[test]
fn deserialize_overlapping_cages_errors() {
let json = r#"{"n":4,"cages":[
{"polyomino":[[1,1]],"operation":"Given","target":1},
{"polyomino":[[1,1]],"operation":"Given","target":2}
]}"#;
assert!(serde_json::from_str::<Puzzle>(json).is_err());
}
#[test]
fn deserialize_infeasible_cage_errors() {
let json = r#"{"n":2,"cages":[
{"polyomino":[[1,1]],"operation":"Given","target":1},
{"polyomino":[[1,2]],"operation":"Given","target":1}
]}"#;
let err = serde_json::from_str::<Puzzle>(json).unwrap_err();
assert!(err.to_string().contains("infeasible cage"));
}
#[test]
fn operators_for_singleton_is_given() {
let poly = Polyomino::from([Cell(1, 1)]).unwrap();
assert_eq!(operators_for(&poly), vec![CageOperator::Given]);
}
#[test]
fn operators_for_domino_is_all_four() {
assert_eq!(
operators_for(&domino(1, 1, 1, 2)),
vec![
CageOperator::Add,
CageOperator::Subtract,
CageOperator::Multiply,
CageOperator::Divide,
]
);
}
#[test]
fn operators_for_triomino_is_commutative_only() {
let poly = Polyomino::from([Cell(1, 1), Cell(1, 2), Cell(1, 3)]).unwrap();
assert_eq!(
operators_for(&poly),
vec![CageOperator::Add, CageOperator::Multiply]
);
}
#[cfg(feature = "without-solution")]
fn equal_pinned_domino() -> (Puzzle, Polyomino) {
let p = Puzzle::new(4)
.unwrap()
.set(Cell(1, 1), 2)
.unwrap()
.set(Cell(1, 2), 2)
.unwrap();
(p, domino(1, 1, 1, 2))
}
#[cfg(feature = "without-solution")]
#[test]
fn possible_targets_subtract_empty_when_range_collapses() {
let (p, poly) = equal_pinned_domino();
assert_eq!(
p.possible_targets(&poly, CageOperator::Subtract).unwrap(),
Vec::<T>::new()
);
}
#[cfg(feature = "without-solution")]
#[test]
fn possible_targets_divide_empty_when_range_collapses() {
let (p, poly) = equal_pinned_domino();
assert_eq!(
p.possible_targets(&poly, CageOperator::Divide).unwrap(),
Vec::<T>::new()
);
}
#[cfg(feature = "without-solution")]
#[test]
fn possible_operations_excludes_ops_with_empty_target_range() {
let (p, poly) = equal_pinned_domino();
let ops = p.possible_operations(&poly).unwrap();
assert!(!ops.contains(&CageOperator::Subtract));
assert!(!ops.contains(&CageOperator::Divide));
}
}