diff --git a/Cargo.lock b/Cargo.lock index d594ec4e31..5e72df25df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2181,6 +2181,7 @@ dependencies = [ "glam", "graphic-types", "node-macro", + "rand", "raster-types", "serde", "vector-types", diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index d65805e33b..8991ccb040 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -108,7 +108,7 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ "graphene_core::transform_nodes::FreezeRealTimeNode", "graphene_core::vector::SubpathSegmentLengthsNode", "core_types::vector::SubpathSegmentLengthsNode", - // The deleted debug Option trio degrades to a passthrough of its single input (audit resolution 8) + // The deleted debug Option trio degrades to a passthrough of its single input "graphene_core::ops::SizeOfNode", "graphene_core::debug::SizeOfNode", "graphene_core::ops::SomeNode", @@ -2765,7 +2765,7 @@ fn migrate_removed_catalog_definitions(node_id: &NodeId, node: &DocumentNode, ne } } - // The removed Attach Attribute node (merged into Write Attribute per audit resolution 6) degrades to a passthrough of its + // The removed Attach Attribute node degrades to a passthrough of its // content: its eager whole-list source input cannot be mechanically rewired as Write Attribute's lazy per-item value producer. if let Some(DefinitionIdentifier::ProtoNode(identifier)) = document.network_interface.reference(node_id, network_path) && identifier.as_str().ends_with("::AttachAttributeNode") diff --git a/node-graph/interpreted-executor/src/dynamic_executor/test.rs b/node-graph/interpreted-executor/src/dynamic_executor/test.rs index cef563fe92..37cba468fb 100644 --- a/node-graph/interpreted-executor/src/dynamic_executor/test.rs +++ b/node-graph/interpreted-executor/src/dynamic_executor/test.rs @@ -346,6 +346,50 @@ fn position_value_converts_through_the_vector_input_adapter() { assert!(result.is_some(), "The position should arrive as an Item single-anchor path"); } +// A scalar wire feeding a `DVec2` connector splats into both axes through the input adapter's `Convert` row +#[test] +fn number_value_splats_through_the_vec2_input_adapter() { + let number_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(-60.).into()), vec![NodeId(0)]); + + let mut input_adapter_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(0)]), vec![NodeId(1)]); + input_adapter_node.identifier = ProtoNodeIdentifier::new("input_adapter"); + + let network = ProtoNetwork { + inputs: vec![], + output: NodeId(1), + nodes: vec![(NodeId(0), number_node), (NodeId(1), input_adapter_node)], + }; + let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY); + typing_context.update(&network).expect("An f64 wire should resolve the adapter's splat conversion row"); + let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("The splat constructor should instantiate"); + + let context: Context = None; + let result: Option> = futures::executor::block_on(tree.eval(NodeId(1), context)); + assert_eq!(result.map(|item| *item.element()), Some(glam::DVec2::splat(-60.)), "The scalar should splat into both axes"); +} + +// A scalar wire feeding a `String` connector formats as text through the input adapter's `Convert` row +#[test] +fn number_value_formats_through_the_string_input_adapter() { + let number_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(42.).into()), vec![NodeId(0)]); + + let mut input_adapter_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(0)]), vec![NodeId(1)]); + input_adapter_node.identifier = ProtoNodeIdentifier::new("input_adapter"); + + let network = ProtoNetwork { + inputs: vec![], + output: NodeId(1), + nodes: vec![(NodeId(0), number_node), (NodeId(1), input_adapter_node)], + }; + let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY); + typing_context.update(&network).expect("An f64 wire should resolve the adapter's formatting conversion row"); + let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("The formatting constructor should instantiate"); + + let context: Context = None; + let result: Option> = futures::executor::block_on(tree.eval(NodeId(1), context)); + assert_eq!(result.map(|item| item.element().clone()), Some("42".to_string()), "The number should format as its text representation"); +} + // A `List` wire feeding a `ListDyn` connector erases its element type through the input adapter's `Into` row #[test] fn list_wire_erases_through_the_list_dyn_input_adapter() { diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 162f4a54e3..a7e38e3d9e 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -411,7 +411,10 @@ fn node_registry() -> HashMap, Color, Gradient, + f32, f64, + u32, + u64, bool, String, DVec2, @@ -485,8 +488,8 @@ fn node_registry() -> HashMap` connector, each number becoming a uniform radius for all four corners node_types.extend(input_adapter_row!(from_element: f64, element: BoxCorners)); - // Numeric wires cast between element types at a ranked connector, as `Convert` does for bare numeric wires - macro_rules! numeric_convert_node { + // The `Convert`-based counterpart of `input_adapter_row!`, for casts the std `Into` trait cannot express + macro_rules! convert_adapter_node { (from_element: $from:ty, element: $element:ty) => {{ let entries: Vec<(ProtoNodeIdentifier, NodeConstructor, NodeIOTypes)> = vec![ input_adapter_row!(node: ConvertItemNode, from: Item<$from>, to: Item<$element>, element: $element), @@ -495,19 +498,24 @@ fn node_registry() -> HashMap {{ let mut entries: Vec<(ProtoNodeIdentifier, NodeConstructor, NodeIOTypes)> = Vec::new(); - $(entries.extend(numeric_convert_node!(from_element: $from, element: $to));)* + $(entries.extend(convert_adapter_node!(from_element: $from, element: $to));)* entries }}; } - node_types.extend(numeric_convert_star!(from: f64, to: [f32, u32, u64, i32, i64])); - node_types.extend(numeric_convert_star!(from: f32, to: [f64, u32, u64, i32, i64])); - node_types.extend(numeric_convert_star!(from: u32, to: [f64, f32, u64, i32, i64])); - node_types.extend(numeric_convert_star!(from: u64, to: [f64, f32, u32, i32, i64])); - node_types.extend(numeric_convert_star!(from: i32, to: [f64, f32, u32, u64, i64])); - node_types.extend(numeric_convert_star!(from: i64, to: [f64, f32, u32, u64, i32])); + // Numeric wires cast between numeric element types, splat to fill both axes of a `DVec2` connector, and format into a `String` connector + node_types.extend(convert_adapter_wildcard!(from: f64, to: [f32, u32, u64, i32, i64, DVec2, String])); + node_types.extend(convert_adapter_wildcard!(from: f32, to: [f64, u32, u64, i32, i64, DVec2, String])); + node_types.extend(convert_adapter_wildcard!(from: u32, to: [f64, f32, u64, i32, i64, DVec2, String])); + node_types.extend(convert_adapter_wildcard!(from: u64, to: [f64, f32, u32, i32, i64, DVec2, String])); + node_types.extend(convert_adapter_wildcard!(from: i32, to: [f64, f32, u32, u64, i64, DVec2, String])); + node_types.extend(convert_adapter_wildcard!(from: i64, to: [f64, f32, u32, u64, i32, DVec2, String])); + // Bool, position, and transform wires may feed a ranked `String` connector by formatting each element as text + node_types.extend(convert_adapter_node!(from_element: bool, element: String)); + node_types.extend(convert_adapter_node!(from_element: DVec2, element: String)); + node_types.extend(convert_adapter_node!(from_element: DAffine2, element: String)); // The sanctioned attribute value conversions: an Item wire's elements box per cell, while a List wire boxes whole as one value macro_rules! attribute_value_node { (Item<$element:ty>) => { diff --git a/node-graph/libraries/core-types/src/ops.rs b/node-graph/libraries/core-types/src/ops.rs index 5720a658ed..507a8df260 100644 --- a/node-graph/libraries/core-types/src/ops.rs +++ b/node-graph/libraries/core-types/src/ops.rs @@ -45,6 +45,14 @@ pub trait Convert: Sized { fn convert(self, footprint: Footprint, converter: C) -> impl Future + Send; } +impl Convert for T { + /// Converts this type into a `String` using its `ToString` implementation. + #[inline] + async fn convert(self, _: Footprint, _converter: ()) -> String { + self.to_string() + } +} + /// Constructs `Self` from a single anchor point at the given position. Implemented by the vector crate's /// path type so a position wire can convert to a single-point path without core-types depending on that crate. pub trait FromAnchorPosition { diff --git a/node-graph/nodes/gcore/src/context.rs b/node-graph/nodes/gcore/src/context.rs index 95d81afff3..b119ed655d 100644 --- a/node-graph/nodes/gcore/src/context.rs +++ b/node-graph/nodes/gcore/src/context.rs @@ -46,6 +46,33 @@ fn read_gradient(ctx: impl Ctx + ExtractVarArgs) -> Item { var_arg.downcast_ref().cloned().unwrap_or_default() } +/// Reads the current number from within a **Map** node's loop. +#[node_macro::node(category("Context"))] +fn read_number(ctx: impl Ctx + ExtractVarArgs) -> Item { + let Ok(var_arg) = ctx.vararg(0) else { return Default::default() }; + let var_arg = var_arg as &dyn std::any::Any; + + if let Some(item) = var_arg.downcast_ref::>() { + return item.clone(); + } + + // Numeric lists carry several possible element types, so probe each and widen to f64, keeping the item's attributes + if let Some(item) = var_arg.downcast_ref::>() { + let (element, attributes) = item.clone().into_parts(); + return Item::from_parts(element as f64, attributes); + } + if let Some(item) = var_arg.downcast_ref::>() { + let (element, attributes) = item.clone().into_parts(); + return Item::from_parts(element as f64, attributes); + } + if let Some(item) = var_arg.downcast_ref::>() { + let (element, attributes) = item.clone().into_parts(); + return Item::from_parts(element as f64, attributes); + } + + Default::default() +} + #[node_macro::node(category("Context"), path(core_types::vector))] async fn read_position( ctx: impl Ctx + ExtractPosition, diff --git a/node-graph/nodes/graphic/Cargo.toml b/node-graph/nodes/graphic/Cargo.toml index c67322948e..5b2594a116 100644 --- a/node-graph/nodes/graphic/Cargo.toml +++ b/node-graph/nodes/graphic/Cargo.toml @@ -17,3 +17,4 @@ dyn-any = { workspace = true } glam = { workspace = true } serde = { workspace = true } node-macro = { workspace = true } +rand = { workspace = true } diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index 6d948863af..dd768f5f80 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -1,11 +1,14 @@ use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::list::{AttributeValueDyn, Item, List, ListDyn, NodeIdPath}; -use core_types::registry::types::{Angle, SignedInteger}; +use core_types::registry::types::{Angle, SeedValue, SignedInteger}; use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_TRANSFORM, AnyHash, BlendMode, CacheHash, CloneVarArgs, Color, Context, Ctx, ExtractAll, OwnedContextImpl}; use glam::{DAffine2, DVec2}; use graphic_types::graphic::{Graphic, IntoGraphicList}; use graphic_types::{Artboard, Vector}; +use rand::SeedableRng; +use rand::seq::SliceRandom; use raster_types::{CPU, GPU, Raster}; +use std::cmp::Ordering; use vector_types::gradient::{GradientSpreadMethod, GradientType}; use vector_types::{Gradient, GradientStop, ReferencePoint}; @@ -45,12 +48,12 @@ pub fn remove_at_index( } } -/// Returns the item at the specified index in a `List`, keeping its attributes. +/// Returns the item at the specified index in a list, keeping its attributes. /// If no value exists at that index, the element type's default is returned. #[node_macro::node(category("General"), name("Item at Index"))] pub fn item_at_index( _: impl Ctx, - /// The `List` of data to take the item from. + /// The list of data to take the item from. #[implementations( List, List, @@ -85,6 +88,328 @@ pub fn item_at_index( list.clone_item(resolved).unwrap_or_default() } +/// Keeps chosen items from a list (those corresponding to `true` values) and discards the others (those corresponding to `false` values) based on the *Keep Pattern* bool list. A short pattern is repeated over the remainder of the filtered list, allowing a pattern like `[true, false]` to keep every other item starting from the first. An empty pattern keeps all items. +#[node_macro::node(category("General"))] +fn filter( + _: impl Ctx, + /// The list of data to filter. + #[implementations( + List, + List, + List, + List, + List, + List, + List, + List, + List, + List, + List>, + List>, + List, + List, + List, + )] + list: List, + /// The list of true and false values that determines which corresponding items are kept (`true`) and discarded (`false`). The pattern may repeat if it is shorter than the list of data. + keep_pattern: List, +) -> List { + // Tile the keep pattern over the items, so a short pattern repeats from the start + let pattern = keep_pattern.iter_element_values().as_slice(); + if pattern.is_empty() { + return list; + } + + list.into_iter().enumerate().filter_map(|(index, item)| pattern[index % pattern.len()].then_some(item)).collect() +} + +/// Reverses the order of the items in a list, so the last item comes first and the first comes last. +#[node_macro::node(category("General"))] +fn reverse( + _: impl Ctx, + /// The list of data to reverse. + #[implementations( + List, + List, + List, + List, + List, + List, + List, + List, + List, + List, + List>, + List>, + List, + List, + List, + )] + list: List, +) -> List { + list.into_iter().rev().collect() +} + +/// Shifts the items in a list by a number of positions. With wrapping, items pushed off one end reappear at the other. Otherwise they are dropped, shortening the list. +#[node_macro::node(category("General"))] +fn shift( + _: impl Ctx, + /// The list of data to shift. + #[implementations( + List, + List, + List, + List, + List, + List, + List, + List, + List, + List, + List>, + List>, + List, + List, + List, + )] + list: List, + /// How many positions to shift each item. Positive values shift items toward the start of the list, negative toward the end. + amount: Item, + /// Whether items shifted off one end wrap around to the other. When off, they are dropped and the list gets shorter. + #[default(true)] + wrap: Item, +) -> List { + let amount = amount.into_element() as i64; + let wrap = wrap.into_element(); + let len = list.len() as i64; + if len == 0 { + return list; + } + + let mut items: Vec> = list.into_iter().collect(); + if wrap { + items.rotate_left((((amount % len) + len) % len) as usize); + items.into_iter().collect() + } else if amount >= 0 { + items.into_iter().skip(amount.min(len) as usize).collect() + } else { + items.into_iter().take((len + amount).max(0) as usize).collect() + } +} + +/// Randomly reorders the items in a list. The same seed always produces the same ordering. +#[node_macro::node(category("General"))] +fn shuffle( + _: impl Ctx, + /// The list to have its items randomly reordered. + #[implementations( + List, + List, + List, + List, + List, + List, + List, + List, + List, + List, + List>, + List>, + List, + List, + List, + )] + list: List, + /// Seed to determine the unique variation of the random shuffle ordering. The same seed always produces the same ordering. + seed: Item, +) -> List { + let seed = seed.into_element(); + let mut items: Vec> = list.into_iter().collect(); + + let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into()); + items.shuffle(&mut rng); + + items.into_iter().collect() +} + +/// Generates a list of evenly spaced numbers, starting at a value and progressing by a step (which may be positive, negative, or zero) for a given count. +#[node_macro::node(category("General"), name("Number Sequence"))] +fn number_sequence( + _: impl Ctx, + _primary: (), + /// The first number in the sequence. + start: Item, + /// The amount added to reach each successive number. + #[default(1.)] + step: Item, + /// How many numbers to generate. + #[default(10)] + count: Item, +) -> List { + let (start, step, count) = (*start.element(), *step.element(), count.into_element()); + + (0..count).map(|i| Item::new_from_element(start + step * i as f64)).collect() +} + +/// Counts out the index of each item in a list (0, 1, 2, and so on), producing a list of numbers with one for each item. +#[node_macro::node(category("General"))] +fn list_indices( + _: impl Ctx, + /// The list whose items are counted. + list: ListDyn, + /// The number that the count begins from for the first item. + start_index: Item, +) -> List { + let start_index = start_index.into_element(); + + (0..list.len()).map(|index| Item::new_from_element(start_index + index as f64)).collect() +} + +/// Extracts a portion of a list, starting at "Start" and ending before "End". +/// +/// Negative indices count from the end of the list. If the index of "Start" equals or exceeds "End", the result is an empty list. +#[node_macro::node(category("General"))] +fn list_slice( + _: impl Ctx, + /// The list of data to take a portion of. + #[implementations( + List, + List, + List, + List, + List, + List, + List, + List, + List, + List, + List>, + List>, + List, + List, + List, + )] + list: List, + /// The index of the first item in the portion. Negative indices count from the end of the list. + start: Item, + /// The index the portion ends before, which is not included. Zero or negative indices count from the end of the list. + end: Item, +) -> List { + let (start, end) = (start.into_element(), end.into_element()); + let total_items = list.len(); + + let start = if start < 0. { + total_items.saturating_sub(start.abs() as usize) + } else { + (start as usize).min(total_items) + }; + let end = if end <= 0. { + total_items.saturating_sub(end.abs() as usize) + } else { + (end as usize).min(total_items) + }; + + if start >= end { + return List::new(); + } + + list.into_iter().skip(start).take(end - start).collect() +} + +/// Pairwise ordering used by the Sort node for element values. Types without a natural +/// order compare as equal, so the stable sort leaves their items in their original relative positions. +pub trait ElementOrder { + fn element_order(&self, _other: &Self) -> Ordering { + Ordering::Equal + } +} +impl ElementOrder for String { + fn element_order(&self, other: &Self) -> Ordering { + self.cmp(other) + } +} +impl ElementOrder for bool { + fn element_order(&self, other: &Self) -> Ordering { + self.cmp(other) + } +} +impl ElementOrder for f32 { + fn element_order(&self, other: &Self) -> Ordering { + self.total_cmp(other) + } +} +impl ElementOrder for f64 { + fn element_order(&self, other: &Self) -> Ordering { + self.total_cmp(other) + } +} +impl ElementOrder for u32 { + fn element_order(&self, other: &Self) -> Ordering { + self.cmp(other) + } +} +impl ElementOrder for u64 { + fn element_order(&self, other: &Self) -> Ordering { + self.cmp(other) + } +} +impl ElementOrder for DVec2 {} +impl ElementOrder for DAffine2 {} +impl ElementOrder for Vector {} +impl ElementOrder for Graphic {} +impl ElementOrder for Raster {} +impl ElementOrder for Raster {} +impl ElementOrder for Color {} +impl ElementOrder for Gradient {} +impl ElementOrder for Artboard {} + +/// Reorders a list's items from smallest to largest, either by each item's own value or by a parallel list of sortable values in the *Sort Order* input. The sort is stable, so items with the same sort order retain their relative positions. +#[node_macro::node(category("General"))] +fn sort( + _: impl Ctx, + /// The list of data to reorder. + #[implementations( + List, List, List, List, List, List, List, List, List, List, List>, List>, List, List, List, + List, List, List, List, List, List, List, List, List, List, List>, List>, List, List, List, + List, List, List, List, List, List, List, List, List, List, List>, List>, List, List, List, + )] + list: List, + /// The optional list of orderable values, corresponding item-to-item with the input list, to sort by instead of the items' own values. + #[expose] + #[implementations( + List, List, List, List, List, List, List, List, List, List, List, List, List, List, List, + List, List, List, List, List, List, List, List, List, List, List, List, List, List, List, + List, List, List, List, List, List, List, List, List, List, List, List, List, List, List, + )] + sort_order: List, + /// Reverses the sorted list order, following descending order instead of ascending (numbers largest-to-smallest, strings Z-to-A, etc.). + reverse: Item, +) -> List { + let reverse = reverse.into_element(); + + // Order by the parallel keys when provided (repeating the last if there are fewer keys than items), otherwise by the element values themselves + let keys = sort_order.iter_element_values().as_slice(); + let elements: Vec<&T> = list.iter_element_values().collect(); + + let mut order: Vec = (0..list.len()).collect(); + order.sort_by(|&a, &b| { + let ordering = match keys { + [] => elements[a].element_order(elements[b]), + keys => keys[a.min(keys.len() - 1)].element_order(&keys[b.min(keys.len() - 1)]), + }; + if reverse { ordering.reverse() } else { ordering } + }); + + let mut result = List::new(); + for index in order { + if let Some(item) = list.clone_item(index) { + result.push(item); + } + } + + result +} + #[node_macro::node(category("General"))] async fn map( ctx: impl Ctx + CloneVarArgs + ExtractAll, @@ -215,8 +540,8 @@ pub fn path_of_subgraph(_: impl Ctx, node_path: Item) -> Item` by the auto-inserted input adapter, so this node only @@ -224,7 +549,7 @@ pub fn path_of_subgraph(_: impl Ctx, node_path: Item) -> Item( ctx: impl ExtractAll + CloneVarArgs + Ctx, - /// The `List` to set the named attribute on (one value per item). + /// The list to set the named attribute on (one value per item). #[implementations( List, List, @@ -473,11 +798,11 @@ fn read_attribute_raster( result } -/// Joins two `List`s of the same type, extending the base `List` with the items from the new `List`. +/// Joins two lists of the same type, extending the base list with the items from the new list. #[node_macro::node(category("General"))] pub async fn extend( _: impl Ctx, - /// The `List` whose items will appear at the start of the extended `List`. + /// The list whose items will appear at the start of the extended list. #[implementations( List, List, @@ -496,7 +821,7 @@ pub async fn extend( List, )] base: List, - /// The `List` whose items will appear at the end of the extended `List`. + /// The list whose items will appear at the end of the extended list. #[expose] #[implementations( List, @@ -724,3 +1049,128 @@ fn colors_to_gradient(_: impl Ctx, #[implementations(List(elements: impl IntoIterator) -> List { + elements.into_iter().map(Item::new_from_element).collect() + } + + fn elements(list: &List) -> Vec { + list.iter_element_values().cloned().collect() + } + + #[test] + fn sorts_elements_by_their_natural_order() { + let list = list_of(["banana".to_string(), "apple".to_string(), "cherry".to_string()]); + let sorted = sort((), list, List::::new(), Item::new_from_element(false)); + assert_eq!(elements(&sorted), ["apple", "banana", "cherry"]); + } + + #[test] + fn sorts_elements_in_reverse() { + let list = list_of([3., 1., 2.]); + let sorted = sort((), list, List::::new(), Item::new_from_element(true)); + assert_eq!(elements(&sorted), [3., 2., 1.]); + } + + #[test] + fn sort_order_keys_override_element_order() { + let list = list_of(["apple".to_string(), "banana".to_string(), "cherry".to_string()]); + let sorted = sort((), list, list_of([2., 0., 1.]), Item::new_from_element(false)); + assert_eq!(elements(&sorted), ["banana", "cherry", "apple"]); + } + + #[test] + fn short_sort_order_repeats_its_last_key() { + let list = list_of(["a".to_string(), "b".to_string(), "c".to_string()]); + let sorted = sort((), list, list_of([2., 1.]), Item::new_from_element(false)); + assert_eq!(elements(&sorted), ["b", "c", "a"]); + } + + #[test] + fn long_sort_order_ignores_its_extra_keys() { + let list = list_of([1., 2.]); + let sorted = sort((), list, list_of([3., 1., 0., 5.]), Item::new_from_element(false)); + assert_eq!(elements(&sorted), [2., 1.]); + } + + #[test] + fn text_sort_order_keys_order_items_alphabetically() { + let list = list_of([1., 2., 3.]); + let sorted = sort((), list, list_of(["c".to_string(), "a".to_string(), "b".to_string()]), Item::new_from_element(false)); + assert_eq!(elements(&sorted), [2., 3., 1.]); + } + + #[test] + fn unsortable_elements_keep_their_original_order() { + let list = list_of([DVec2::new(3., 3.), DVec2::new(1., 1.), DVec2::new(2., 2.)]); + let sorted = sort((), list, List::::new(), Item::new_from_element(false)); + assert_eq!(elements(&sorted), [DVec2::new(3., 3.), DVec2::new(1., 1.), DVec2::new(2., 2.)]); + } + + #[test] + fn shift_wraps_items_around() { + let forward = shift((), list_of([1., 2., 3., 4.]), Item::new_from_element(1.), Item::new_from_element(true)); + assert_eq!(elements(&forward), [2., 3., 4., 1.]); + + let backward = shift((), list_of([1., 2., 3., 4.]), Item::new_from_element(-1.), Item::new_from_element(true)); + assert_eq!(elements(&backward), [4., 1., 2., 3.]); + } + + #[test] + fn shift_without_wrapping_drops_items() { + let dropped_front = shift((), list_of([1., 2., 3., 4.]), Item::new_from_element(1.), Item::new_from_element(false)); + assert_eq!(elements(&dropped_front), [2., 3., 4.]); + + let dropped_back = shift((), list_of([1., 2., 3., 4.]), Item::new_from_element(-1.), Item::new_from_element(false)); + assert_eq!(elements(&dropped_back), [1., 2., 3.]); + } + + #[test] + fn shuffle_is_deterministic_and_preserves_elements() { + let original = [1., 2., 3., 4., 5., 6., 7., 8.]; + let first = shuffle((), list_of(original), Item::new_from_element(42_u32)); + let second = shuffle((), list_of(original), Item::new_from_element(42_u32)); + assert_eq!(elements(&first), elements(&second), "the same seed should always produce the same ordering"); + + let mut recovered = elements(&first); + recovered.sort_by(|a, b| a.partial_cmp(b).unwrap()); + assert_eq!(recovered, original, "shuffling should preserve all the elements"); + } + + #[test] + fn number_sequence_generates_evenly_spaced_numbers() { + let sequence = number_sequence((), (), Item::new_from_element(0.), Item::new_from_element(2.), Item::new_from_element(4_u32)); + assert_eq!(elements(&sequence), [0., 2., 4., 6.]); + } + + #[test] + fn list_indices_counts_each_item() { + let indices = list_indices((), ListDyn::from(list_of(["a".to_string(), "b".to_string(), "c".to_string()])), Item::new_from_element(0.)); + assert_eq!(elements(&indices), [0., 1., 2.]); + + let from_one = list_indices((), ListDyn::from(list_of(["a".to_string(), "b".to_string(), "c".to_string()])), Item::new_from_element(1.)); + assert_eq!(elements(&from_one), [1., 2., 3.]); + } + + #[test] + fn list_slice_takes_the_portion_between_start_and_end() { + let portion = list_slice((), list_of([1., 2., 3., 4., 5.]), Item::new_from_element(1.), Item::new_from_element(3.)); + assert_eq!(elements(&portion), [2., 3.]); + } + + #[test] + fn list_slice_resolves_negative_indices_from_the_end() { + let portion = list_slice((), list_of([1., 2., 3., 4., 5.]), Item::new_from_element(-2.), Item::new_from_element(0.)); + assert_eq!(elements(&portion), [4., 5.], "an end of zero reaches through the end of the list"); + } + + #[test] + fn list_slice_yields_nothing_when_start_reaches_end() { + let portion = list_slice((), list_of([1., 2., 3., 4., 5.]), Item::new_from_element(3.), Item::new_from_element(3.)); + assert!(elements(&portion).is_empty()); + } +}