Merge pull request #2 from avimallu/add_proportion

Add 'Split by Proportion' and 'Remove Item' features
This commit is contained in:
avimallu
2025-08-28 10:28:21 -05:00
committed by GitHub
12 changed files with 512 additions and 183 deletions

View File

@@ -5,14 +5,14 @@
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset="UTF-8" />
<link rel="preload" as="script" href="/BorrowChecker/assets/borrow_checker-0061ef486a3a77ac.js" crossorigin><link rel="preload" as="fetch" type="application/wasm" href="/BorrowChecker/assets/borrow_checker_bg-700a1215554002db.wasm" crossorigin></head>
<link rel="preload" as="script" href="/BorrowChecker/assets/borrow_checker-431cb889995abfdd.js" crossorigin><link rel="preload" as="fetch" type="application/wasm" href="/BorrowChecker/assets/borrow_checker_bg-27678ce938d4a71f.wasm" crossorigin></head>
<body>
<div id="main"></div>
<script>
// We can't use a module script here because we need to start the script immediately when streaming
import("/BorrowChecker/assets/borrow_checker-0061ef486a3a77ac.js").then(
import("/BorrowChecker/assets/borrow_checker-431cb889995abfdd.js").then(
({ default: init }) => {
init("/BorrowChecker/assets/borrow_checker_bg-700a1215554002db.wasm").then((wasm) => {
init("/BorrowChecker/assets/borrow_checker_bg-27678ce938d4a71f.wasm").then((wasm) => {
if (wasm.__wbindgen_start == undefined) {
wasm.main();
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -5,14 +5,14 @@
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset="UTF-8" />
<link rel="preload" as="script" href="/BorrowChecker/assets/borrow_checker-0061ef486a3a77ac.js" crossorigin><link rel="preload" as="fetch" type="application/wasm" href="/BorrowChecker/assets/borrow_checker_bg-700a1215554002db.wasm" crossorigin></head>
<link rel="preload" as="script" href="/BorrowChecker/assets/borrow_checker-431cb889995abfdd.js" crossorigin><link rel="preload" as="fetch" type="application/wasm" href="/BorrowChecker/assets/borrow_checker_bg-27678ce938d4a71f.wasm" crossorigin></head>
<body>
<div id="main"></div>
<script>
// We can't use a module script here because we need to start the script immediately when streaming
import("/BorrowChecker/assets/borrow_checker-0061ef486a3a77ac.js").then(
import("/BorrowChecker/assets/borrow_checker-431cb889995abfdd.js").then(
({ default: init }) => {
init("/BorrowChecker/assets/borrow_checker_bg-700a1215554002db.wasm").then((wasm) => {
init("/BorrowChecker/assets/borrow_checker_bg-27678ce938d4a71f.wasm").then((wasm) => {
if (wasm.__wbindgen_start == undefined) {
wasm.main();
}

View File

@@ -18,7 +18,7 @@ pub fn SplitUI() -> Element {
}
}
div { class: "section",
div { class: "container is-fluid",
div { class: "container",
for item_idx in 0..item_count {
SplitItemUI { item_idx }
}
@@ -48,7 +48,6 @@ pub fn SplitUI() -> Element {
icon: ld_icons::LdBookPlus,
}
span { class: "ml-2", "Add Item" }
}
}
div {
@@ -76,7 +75,7 @@ pub fn SplitUI() -> Element {
}
footer { class: "hero is-small is-primary",
div { class: "hero-body has-text-centered is-flex is-justify-content-center",
p { class: "subtitle is-size-7 mr-1", "Built with Rust & Dioxus | " }
p { class: "subtitle is-size-7 mr-1", "% = split proportionally | " }
p { class: "subtitle is-size-7 mr-1", "👾🤖👻 |" }
a {
class: "subtitle is-size-7 mr-1",
@@ -100,10 +99,10 @@ fn ColorBalanceTitle(balance: Decimal) -> Element {
p { class: "subtitle is-size-5", "left to balance" }
} else if balance < Decimal::ZERO {
p { class: "title has-text-danger is-size-4", "Remaining: {balance}" }
p { class: "subtitle is-size-6", "Item total exceeds receipt total." }
p { class: "subtitle is-size-6", "item totals > receipt total" }
} else {
p { class: "title has-text-link is-size-4", "0" }
p { class: "subtitle is-size-6", "Perfectly balanced, as all things should be." }
p { class: "subtitle is-size-6", "balanced" }
}
}
}
@@ -111,21 +110,37 @@ fn ColorBalanceTitle(balance: Decimal) -> Element {
#[component]
fn SplitItemUI(item_idx: usize) -> Element {
let people_list = (*RECEIPT_STATE.read()).as_ref().unwrap().shared_by.clone();
let disable_proportional_button: String = match (*RECEIPT_STATE.read())
.as_ref()
.unwrap()
.is_proportionally_splittable(item_idx)
{
false => "true".into(),
true => "false".into(),
};
let disable_removal_button: String = match (*RECEIPT_STATE.read())
.as_ref()
.unwrap()
.is_removable(item_idx)
{
false => "true".into(),
true => "false".into(),
};
let (item_name, item_value, item_shared_by) = &RECEIPT_STATE
.read()
let (item_name, item_value, item_shared_by, item_is_prop_dist) = (&RECEIPT_STATE.read())
.as_ref()
.and_then(|r| r.items.get(item_idx))
.map(|item| {
(
item.name.clone(),
item.value.clone(),
item.value, //.clone(),
item.shared_by.clone(),
item.is_prop_dist, //.clone(),
)
})
.unwrap_or_default();
let item_value = if item_value > &Decimal::ZERO {
let item_value = if item_value > Decimal::ZERO {
item_value.to_string()
} else {
"-".to_string()
@@ -133,8 +148,8 @@ fn SplitItemUI(item_idx: usize) -> Element {
if true {
rsx! {
div { class: "columns is-mobile",
div { class: "column is-two-thirds",
div { class: "columns is-mobile is-1 is-vcentered",
div { class: "column is-5",
input {
class: "input is-primary",
key: "item_input_name_{item_idx}",
@@ -150,7 +165,7 @@ fn SplitItemUI(item_idx: usize) -> Element {
placeholder: "item name",
}
}
div { class: "column is-one-third",
div { class: "column is-3",
input {
class: "input is-primary",
key: "item_input_value_{item_idx}",
@@ -171,7 +186,51 @@ fn SplitItemUI(item_idx: usize) -> Element {
}
}
},
placeholder: "amount",
placeholder: "$",
}
}
div { class: "column is-2",
button {
class: if item_is_prop_dist { "button is-dark is-info is-fullwidth" } else { "button is-dark is-outlined is-info is-fullwidth" },
key: "item_proportional_button_{item_idx}",
disabled: disable_proportional_button,
onclick: move |_| {
if let Some(receipt) = RECEIPT_STATE.write().as_mut() {
receipt
.update_item_at_index(
item_idx,
None,
None,
None,
Some(!item_is_prop_dist.clone()),
)
.unwrap();
}
},
Icon {
width: 24,
height: 24,
fill: "white",
icon: ld_icons::LdPercent,
}
}
}
div { class: "column is-2",
button {
class: "button is-dark is-danger is-fullwidth",
key: "item_delete_button_{item_idx}",
disabled: disable_removal_button,
onclick: move |_| {
if let Some(receipt) = RECEIPT_STATE.write().as_mut() {
receipt.remove_item_at_index(item_idx).unwrap();
}
},
Icon {
width: 24,
height: 24,
fill: "white",
icon: ld_icons::LdCircleX,
}
}
}
}
@@ -182,20 +241,34 @@ fn SplitItemUI(item_idx: usize) -> Element {
class: if item_shared_by.contains(&person) { "button is-primary is-dark is-fullwidth" } else { "button is-primary is-outlined is-dark is-fullwidth" },
key: "item_{item_idx}_person_{person_idx}",
onclick: move |_| {
if let Some(r) = RECEIPT_STATE.write().as_mut() {
if let Some(item) = r.items.get_mut(item_idx) {
if item.shared_by.contains(&person) && item.shared_by.len() > 1 {
let shared_by_idx = item
.shared_by
.iter()
.position(|name| *name == *person)
.unwrap();
item.shared_by.remove(shared_by_idx);
item.share_ratio.remove(shared_by_idx);
} else {
item.shared_by.push(person.clone());
item.share_ratio.push(Decimal::ONE);
}
if let Some(receipt) = RECEIPT_STATE.write().as_mut() {
let mut new_item_shared_by = receipt.items[item_idx].shared_by.clone();
if new_item_shared_by.contains(&person) && new_item_shared_by.len() > 1 {
let new_item_shared_by = new_item_shared_by
.iter()
.filter(|&x| *x != person)
.cloned()
.collect();
receipt
.update_item_at_index(
item_idx,
None,
None,
Some(new_item_shared_by),
None,
)
.unwrap();
} else if !new_item_shared_by.contains(&person) {
new_item_shared_by.push(person.clone());
receipt
.update_item_at_index(
item_idx,
None,
None,
Some(new_item_shared_by),
None,
)
.unwrap();
}
}
},

View File

@@ -1,110 +0,0 @@
use crate::core::receipt::Receipt;
use dioxus::prelude::*;
use rust_decimal::prelude::*;
static CSS: Asset = asset!("/assets/bulma.css");
#[derive(Clone, PartialEq, Eq)]
enum AppState {
CreatingReceipt,
SplittingItems,
DisplayingSplits,
}
#[component]
pub fn App() -> Element {
let app_state = use_signal(|| AppState::CreatingReceipt);
// Variables to create the essential values of the receipt
let receipt_value: Signal<Option<Decimal>> = use_signal(|| None);
let people_input: Signal<Vec<String>> = use_signal(|| vec!["".to_string()]);
let people_list: Memo<Vec<String>> = use_memo(move || {
people_input
.read()
.iter()
.filter(|name| !name.is_empty())
.cloned()
.collect()
});
// Variables to input and handle receipt items
let receipt: Signal<Option<Receipt>> = use_signal(|| None);
rsx! {
document::Stylesheet { href: CSS }
section { class: "hero is-primary",
div { class: "hero-body",
p { class: "title", "Iron Abacus" }
p { class: "subtitle", "A splitting helper" }
}
}
if app_state() == AppState::CreatingReceipt {
CreateReceipt {
app_state,
receipt_value,
people_input,
people_list,
receipt,
}
} else if app_state() == AppState::SplittingItems {
SplitReceipt {
receipt_value,
people_list,
app_state,
receipt,
}
} else {
DisplayTable { app_state, receipt }
}
}
}
#[component]
fn DisplayTable(mut app_state: Signal<AppState>, mut receipt: Signal<Option<Receipt>>) -> Element {
if let Some(valid_receipt) = receipt().as_ref() {
let mut header = valid_receipt.shared_by.clone();
header.insert(0, "Item Name".into());
header.push("Total".into());
let (item_names, item_splits) = valid_receipt.calculate_splits()?;
let rows: Vec<Vec<String>> = item_names
.into_iter()
.zip(item_splits.into_iter())
.map(|(item_name, splits)| {
let mut splits_as_str: Vec<String> = splits.iter().map(|x| x.to_string()).collect();
splits_as_str.insert(0, item_name.into());
splits_as_str
})
.collect();
rsx! {
div { class: "table-container",
table { class: "table",
thead {
tr {
for val in header.iter() {
th { scope: "col", "{val}" }
}
}
}
tbody {
for row in rows.iter() {
tr {
for (idx , val) in row.iter().enumerate() {
if idx == 0 {
th { scope: "row", "{val}" }
} else {
td { "{val}" }
}
}
}
}
}
}
}
}
} else {
rsx! { "No table to show yet, bitches!" }
}
}

View File

@@ -38,6 +38,8 @@ pub enum SplittingError {
ItemTotalExceedsReceiptTotal(String),
DecimalParsingError(String),
InvalidArgument(String),
InvalidIndexError(String),
NotProportionallySplittableError(String),
}
impl From<rust_decimal::Error> for SplittingError {
@@ -61,6 +63,8 @@ impl fmt::Display for SplittingError {
Self::ItemTotalExceedsReceiptTotal(msg) => write!(f, "{}", msg),
Self::DecimalParsingError(msg) => write!(f, "{}", msg),
Self::InvalidArgument(msg) => write!(f, "{}", msg),
Self::InvalidIndexError(msg) => write!(f, "{}", msg),
Self::NotProportionallySplittableError(msg) => write!(f, "{}", msg),
}
}
}
@@ -130,13 +134,9 @@ impl Receipt {
Ok(self)
}
// Obtain a single vector with the exact splits, with or without items with
// the is_prop_dist attribute as true
fn calculate_overall_proportion(&self, remove_proportional_items: bool) -> Vec<Decimal> {
let items = self
.items
.iter()
.filter(|&x| !remove_proportional_items || !x.is_prop_dist);
// Obtain a single vector with the exact splits
fn calculate_receipt_proportions(&self) -> Vec<Decimal> {
let items = self.items.iter().filter(|&x| !x.is_prop_dist);
let mut receipt_split: Vec<Decimal> = vec![Decimal::ZERO; self.shared_by.len()];
for item in items {
@@ -166,6 +166,31 @@ impl Receipt {
receipt_split
}
pub fn calculate_item_share_ratio_by_proportion(
&self,
shared_by: &Vec<String>,
value: Decimal,
) -> Vec<Decimal> {
// Align the proportional splits to the current shared_by ratios
let pre_prop_splits: Vec<Decimal> = self
.calculate_receipt_proportions()
.iter()
.zip(self.shared_by.iter())
.filter(|(_, person)| shared_by.contains(*person))
.map(|(split, _)| split.clone())
.collect();
let pre_prop_split_total: Decimal = pre_prop_splits.iter().sum();
let proportional_splits: Vec<Decimal> = pre_prop_splits
.iter()
.map(|x| x / pre_prop_split_total)
.collect();
return proportional_splits
.iter()
.map(|ratio| (ratio * value).round_dp(2))
.collect();
}
pub fn add_item_split_by_proportion(
&mut self,
value: Decimal,
@@ -173,42 +198,17 @@ impl Receipt {
shared_by: Vec<String>,
) -> Result<&mut Self, SplittingError> {
if shared_by.len() == 0 {
return Err(SplittingError::NotEnoughPeopleError(format!(
"The number of people sharing the item {} is currently {}. It must be shared by at least 1 person.",
name,
shared_by.len()
return Err(SplittingError::InvalidShareConfiguration(format!(
"Number of people sharing this receipt cannot be zero."
)));
} else if name.is_empty() {
}
if name.is_empty() {
return Err(SplittingError::InvalidFieldError(
"Item name cannot be empty".into(),
));
}
// These vectors are aligned with the order of self.shared_by
let pre_prop_splits = self.calculate_overall_proportion(true);
let pre_prop_split_total: Decimal = pre_prop_splits.iter().sum();
let pre_prop_ratios: Vec<Decimal> = pre_prop_splits
.iter()
.map(|x| x / pre_prop_split_total)
.collect();
// This vector's length will be identical to shared_by
let share_ratio: Vec<Decimal> = shared_by
.iter()
.map(|sharer| {
pre_prop_ratios
.iter()
.zip(self.shared_by.iter())
.find(|&(_, person)| *sharer == *person)
.map(|(&ratio, _)| ratio * value)
.ok_or_else(|| {
SplittingError::InternalError(format!(
"The sharer {} was not found among the original sharers.",
sharer,
))
})
})
.collect::<Result<_, _>>()?;
let share_ratio = self.calculate_item_share_ratio_by_proportion(&shared_by, value);
self.items.push(ReceiptItem {
value,
@@ -254,6 +254,9 @@ impl Receipt {
let mut splits: Vec<Decimal> = self
.shared_by
.iter()
// If the person is sharing the item, specify the share as the person's share
// divided by the item's total shares. This means that an item can be shared
// proportional to other costs by fewer people than those present in the receipt.
.map(|x| match item.shared_by.iter().position(|name| name == x) {
Some(pos) => (item.value * item.share_ratio[pos]
/ item.share_ratio.iter().sum::<Decimal>())
@@ -269,7 +272,7 @@ impl Receipt {
// Add unaccounted item, if present
if leftover_amount > Decimal::ZERO {
let overall_prop = self.calculate_overall_proportion(true);
let overall_prop = self.calculate_receipt_proportions();
let overall_prop_sum: Decimal = overall_prop.iter().sum();
let mut splits: Vec<Decimal> = overall_prop
.iter()
@@ -289,11 +292,150 @@ impl Receipt {
Ok((item_names, all_splits))
}
pub fn is_proportionally_splittable(&self, index: usize) -> bool {
// If there is only one item, it is not proportionally splittable
if self.items.len() <= 1 || index > self.items.len() {
return false;
}
// A ReceiptItem can be split proportionally iff at least ONE
// other receipt item is not split by proportion.
return self
.items
.iter()
.enumerate()
.filter(|(idx, _)| *idx != index)
.any(|(_, x)| !x.is_prop_dist);
}
pub fn recalculate_proportions(&mut self) {
let mut item_share_ratios: Vec<Vec<Decimal>> = Vec::new();
for item in self.items.iter().filter(|x| x.is_prop_dist) {
item_share_ratios
.push(self.calculate_item_share_ratio_by_proportion(&item.shared_by, item.value));
}
for (item, share_ratio) in self
.items
.iter_mut()
.filter(|x| x.is_prop_dist)
.zip(item_share_ratios.into_iter())
{
item.share_ratio = share_ratio
}
}
pub fn update_item_at_index(
&mut self,
idx: usize,
value: Option<Decimal>,
name: Option<String>,
shared_by: Option<Vec<String>>,
is_prop_dist: Option<bool>,
) -> Result<(), SplittingError> {
let is_proportionally_splittable = self.is_proportionally_splittable(idx);
if let Some(receipt_item) = self.items.get_mut(idx) {
// We could use `map` here to be succinct, but that's supposed to be an
// anti-pattern? "Don't use map for its side effect".
if let Some(value_) = value {
receipt_item.value = value_
}
if let Some(name_) = name {
receipt_item.name = name_
}
// Learning: Decimal, bool and String implement copy, but Vec<String>
// does not, that is why a manual `.clone()` is required here.
if let Some(shared_by_) = shared_by.clone() {
receipt_item.shared_by = shared_by_;
receipt_item.share_ratio = vec![Decimal::ONE; receipt_item.shared_by.len()];
}
if let Some(is_prop_dist_) = is_prop_dist {
if is_prop_dist_ && is_proportionally_splittable {
// Setting is_prop_dist to true when possible
receipt_item.is_prop_dist = true;
receipt_item.shared_by = self.shared_by.clone();
} else if !is_prop_dist_ {
// Setting is_prop_dist to false and sharing it across all people
receipt_item.is_prop_dist = false;
receipt_item.shared_by = self.shared_by.clone();
receipt_item.share_ratio = vec![Decimal::ONE; self.shared_by.len()];
} else {
return Err(SplittingError::NotProportionallySplittableError(
"There aren't enough items left to split proportionally on".into(),
));
}
}
} else {
return Err(SplittingError::InvalidIndexError(
"Provide index is outside the range of items present in the receipt".into(),
));
}
// Setting the item as (not) proportional means that it is (no longer) determining
// proportional splits for other items. Therefore, this proportion needs to be recalculated.
// This is true for any change except for a change in name of an underlying item, as long
// as proportional items exist.
// When the last proportional item is changed to being non-proportional, the adjustment
// to its own value is already made in the `if !is_prop_dist` branch above, so no further
// changes need to be made.
if self.items.iter().filter(|x| x.is_prop_dist).count() > 0
&& (value.is_some() || shared_by.is_some() || is_prop_dist.is_some())
{
self.recalculate_proportions()
}
Ok(())
}
pub fn is_removable(&self, idx: usize) -> bool {
let proportional_count = self.items.iter().filter(|x| x.is_prop_dist).count();
if idx >= self.items.len() {
return false;
// Err(SplittingError::InvalidIndexError(
// "Provided index is out of bounds".to_string(),
// ));
}
// Disallow removal of the last proportional item since the rest depend on it
else if self.items.iter().filter(|x| !x.is_prop_dist).count() == 1
&& proportional_count > 0
&& !self.items.get(idx).unwrap().is_prop_dist
{
return false;
// Err(SplittingError::InvalidIndexError(
// "The last non-proportional item cannot be removed when there are proportional items in the receipt.".into()
// ));
}
true
}
// Removing, as opposed to updating, is a far simpler operation - just remove the
// index specified, and update all other values that depend on proportion. Voila!
pub fn remove_item_at_index(&mut self, idx: usize) -> Result<(), SplittingError> {
let proportional_count = self.items.iter().filter(|x| x.is_prop_dist).count();
if self.is_removable(idx) {
self.items.remove(idx);
} else {
}
if proportional_count > 0 {
self.recalculate_proportions();
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::core::receipt::Receipt;
use crate::core::receipt::{Receipt, SplittingError};
use crate::utils;
use rust_decimal::prelude::*;
@@ -332,4 +474,228 @@ mod tests {
];
assert_eq!(expected_splits, actual_splits);
}
#[test]
fn test_is_proportional() {
let mut receipt = Receipt::new(dec![300], vec!["Alice", "Bob", "Marshall"]).unwrap();
assert_eq!(receipt.is_proportionally_splittable(100), false);
let _ = receipt.add_item_split_by_ratio(
dec![25],
"Hearty Burger".into(),
utils::strs_to_strings(vec!["Alice"]),
None,
);
assert_eq!(receipt.is_proportionally_splittable(0), false);
let _ = receipt.add_item_split_by_ratio(
dec![10],
"Vegan Salad".into(),
utils::strs_to_strings(vec!["Marshall"]),
None,
);
assert_eq!(receipt.is_proportionally_splittable(0), true);
}
fn proportional_receipt_helper() -> Result<Receipt, SplittingError> {
let mut receipt = Receipt::new(dec![300], vec!["Alice", "Bob", "Marshall"])?;
receipt
.add_item_split_by_ratio(
dec![30],
"Hearty Burger".into(),
utils::strs_to_strings(vec!["Alice"]),
None,
)?
.add_item_split_by_ratio(
dec![60],
"Unhealthy Burger".into(),
utils::strs_to_strings(vec!["Bob"]),
None,
)?
.add_item_split_by_ratio(
dec![15],
"Vegan Salad".into(),
utils::strs_to_strings(vec!["Marshall"]),
None,
)?
.add_item_split_by_proportion(
dec![50],
"Tax".into(),
utils::strs_to_strings(vec!["Alice", "Bob"]),
)?
.add_item_split_by_proportion(
dec![50],
"Tip".into(),
utils::strs_to_strings(vec!["Bob", "Marshall"]),
)?;
Ok(receipt)
}
#[test]
fn test_adding_by_proportions() {
let receipt = proportional_receipt_helper().unwrap();
assert_eq!(
receipt.items[3].share_ratio,
f64s_to_decimals(&[16.67, 33.33])
);
assert_eq!(
receipt.items[4].share_ratio,
f64s_to_decimals(&[40.0, 10.0])
);
println!("{:#?}", receipt);
}
#[test]
fn test_updated_items() {
let mut receipt = proportional_receipt_helper().unwrap();
// At this point, the receipt is:
// # Alice Bob Marshall Is Prop?
// 0 30
// 1 60
// 2 15
// 3 16.67 33.33 x
// 4 40 37.5 x
let _ = receipt.update_item_at_index(2, Some(dec![30]), None, None, None);
// At this point, the receipt should be:
// # Alice Bob Marshall Is Prop?
// 0 30
// 1 60
// 2 30
// 3 16.67 33.33 x
// 4 33.33 16.67 x
assert_eq!(
receipt.items[3].share_ratio,
f64s_to_decimals(&[16.67, 33.33])
);
assert_eq!(
receipt.items[4].share_ratio,
f64s_to_decimals(&[33.33, 16.67])
);
let _ = receipt.update_item_at_index(2, None, Some("Vegan Air".into()), None, None);
assert_eq!(
receipt.items[3].share_ratio,
f64s_to_decimals(&[16.67, 33.33])
);
assert_eq!(
receipt.items[4].share_ratio,
f64s_to_decimals(&[33.33, 16.67])
);
assert_eq!(receipt.items[2].name, "Vegan Air");
let _ = receipt.update_item_at_index(
1,
None,
None,
Some(utils::strs_to_strings(vec!["Bob", "Marshall"])),
None,
);
// At this point, the receipt should be:
// # Alice Bob Marshall Is Prop?
// 0 30
// 1 30 30
// 2 30
// 3 25 25 x
// 4 16.67 33.33 x
assert_eq!(receipt.items[1].shared_by, vec!["Bob", "Marshall"]);
assert_eq!(
receipt.items[3].share_ratio,
f64s_to_decimals(&[25.0, 25.0])
);
assert_eq!(
receipt.items[4].share_ratio,
f64s_to_decimals(&[16.67, 33.33])
);
let _ = receipt.update_item_at_index(4, None, None, None, Some(false));
// At this point, the receipt should be:
// # Alice Bob Marshall Is Prop?
// 1 30
// 2 30 30
// 3 30
// 4 25 25 x
// 5 13.33 13.33 13.33 x
assert_eq!(
receipt.items[3].share_ratio,
f64s_to_decimals(&[25.0, 25.0])
);
assert_eq!(
receipt.items[4].share_ratio,
f64s_to_decimals(&[1.0, 1.0, 1.0])
);
for i in 0..3 {
let _ = receipt.update_item_at_index(i, None, None, None, Some(true));
}
// This should fail since this is the last non-proportional item (3 is already proportional)
let result = receipt.update_item_at_index(4, None, None, None, Some(true));
assert!(matches!(
result,
Err(SplittingError::NotProportionallySplittableError(_))
));
// Should work fine now!
let _ = receipt.update_item_at_index(3, None, None, None, Some(false));
let result = receipt.update_item_at_index(4, None, None, None, Some(true));
assert_eq!(result, Ok(()));
}
#[test]
fn test_removing_items() {
let mut receipt_1 = proportional_receipt_helper().unwrap();
let mut receipt_2 = proportional_receipt_helper().unwrap();
// Starting point of the receipt is
// # Alice Bob Marshall Is Prop?
// 0 30
// 1 60
// 2 15
// 3 25 25 x
// 4 40 10 x
let _ = receipt_1.remove_item_at_index(2);
assert_eq!(
receipt_1.items[3].share_ratio,
f64s_to_decimals(&[50.0, 0.0])
);
let _ = receipt_2.update_item_at_index(
1,
None,
None,
Some(utils::strs_to_strings(vec!["Alice", "Bob", "Marshall"])),
None,
);
// At this point, the receipt is:
// # Alice Bob Marshall Is Prop?
// 0 30
// 1 20 20 20
// 2 15
// 3 37.5 12.5 x
// 4 12.5 37.5 x
assert_eq!(
receipt_2.items[3].share_ratio,
f64s_to_decimals(&[35.71, 14.29])
);
assert_eq!(
receipt_2.items[4].share_ratio,
f64s_to_decimals(&[18.18, 31.82])
);
let _ = receipt_2.remove_item_at_index(1);
// At this point, the receipt should be:
// # Alice Bob Marshall Is Prop?
// 0 30
// 1 15
// 2 50 x
// 3 50 x
assert_eq!(
receipt_2.items[2].share_ratio,
f64s_to_decimals(&[50.0, 0.0])
);
assert_eq!(
receipt_2.items[3].share_ratio,
f64s_to_decimals(&[0.0, 50.0])
);
}
}