solverforge_core/solver/
builder.rs

1//! High-level builder API for creating solvers from `PlanningSolution` types.
2//!
3//! The `SolverBuilder` provides an ergonomic way to create solvers by automatically
4//! extracting domain models, constraints, and generating WASM modules from types
5//! that implement the `PlanningSolution` trait.
6//!
7//! # Example
8//!
9//! ```ignore
10//! use solverforge::prelude::*;
11//!
12//! // Define your solution type with derive macros
13//! #[derive(PlanningSolution, Clone)]
14//! struct Timetable {
15//!     #[planning_entity_collection]
16//!     lessons: Vec<Lesson>,
17//!     #[planning_score]
18//!     score: HardSoftScore,
19//! }
20//!
21//! // Build and configure the solver
22//! let solver = SolverBuilder::<Timetable>::new()
23//!     .with_termination(TerminationConfig::new().with_spent_limit("PT30S"))
24//!     .with_environment_mode(EnvironmentMode::Reproducible)
25//!     .build::<NoBridge>()?;
26//!
27//! // Solve
28//! let solution = solver.solve(problem)?;
29//! ```
30//!
31//! # Termination
32//!
33//! Configure when solving stops using [`TerminationConfig`]:
34//!
35//! - Time limit: `with_spent_limit("PT5M")` - stop after 5 minutes
36//! - Unimproved time: `with_unimproved_spent_limit("PT30S")` - stop if no improvement for 30s
37//! - Feasibility: `with_best_score_feasible(true)` - stop when a feasible solution is found
38//! - Move count: `with_move_count_limit(100000)` - stop after 100k moves
39//!
40//! # Environment Modes
41//!
42//! - `EnvironmentMode::Reproducible` - deterministic solving (default for testing)
43//! - `EnvironmentMode::FastAssert` - assertions enabled, random solving
44//! - `EnvironmentMode::FullAssert` - all assertions, slowest but catches bugs
45
46use crate::bridge::LanguageBridge;
47use crate::constraints::ConstraintSet;
48use crate::domain::DomainModel;
49use crate::error::{SolverForgeError, SolverForgeResult};
50use crate::solver::{
51    EnvironmentMode, HttpSolverService, MoveThreadCount, SolverConfig, SolverService,
52    TerminationConfig,
53};
54use crate::traits::PlanningSolution;
55use crate::wasm::{HostFunctionRegistry, WasmModuleBuilder};
56use std::marker::PhantomData;
57use std::sync::Arc;
58
59/// Default service URL for the solver service.
60pub const DEFAULT_SERVICE_URL: &str = "http://localhost:8080";
61
62/// Builder for creating solvers from `PlanningSolution` types.
63///
64/// This builder automatically extracts domain models and constraints from the
65/// solution type and generates the required WASM module.
66///
67/// # Type Parameters
68///
69/// - `S`: The solution type that implements `PlanningSolution`
70pub struct SolverBuilder<S: PlanningSolution> {
71    service_url: String,
72    termination: Option<TerminationConfig>,
73    environment_mode: Option<EnvironmentMode>,
74    random_seed: Option<u64>,
75    move_thread_count: Option<MoveThreadCount>,
76    custom_service: Option<Arc<dyn SolverService>>,
77    _phantom: PhantomData<S>,
78}
79
80impl<S: PlanningSolution> Default for SolverBuilder<S> {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl<S: PlanningSolution> SolverBuilder<S> {
87    /// Creates a new `SolverBuilder` with default settings.
88    ///
89    /// The default service URL is `http://localhost:8080`.
90    pub fn new() -> Self {
91        Self {
92            service_url: DEFAULT_SERVICE_URL.to_string(),
93            termination: None,
94            environment_mode: None,
95            random_seed: None,
96            move_thread_count: None,
97            custom_service: None,
98            _phantom: PhantomData,
99        }
100    }
101
102    /// Sets the URL of the solver service.
103    pub fn with_service_url(mut self, url: impl Into<String>) -> Self {
104        self.service_url = url.into();
105        self
106    }
107
108    /// Sets the termination configuration.
109    pub fn with_termination(mut self, termination: TerminationConfig) -> Self {
110        self.termination = Some(termination);
111        self
112    }
113
114    /// Sets the environment mode for the solver.
115    pub fn with_environment_mode(mut self, mode: EnvironmentMode) -> Self {
116        self.environment_mode = Some(mode);
117        self
118    }
119
120    /// Sets the random seed for reproducible solving.
121    pub fn with_random_seed(mut self, seed: u64) -> Self {
122        self.random_seed = Some(seed);
123        self
124    }
125
126    /// Sets the move thread count for parallel solving.
127    pub fn with_move_thread_count(mut self, count: MoveThreadCount) -> Self {
128        self.move_thread_count = Some(count);
129        self
130    }
131
132    /// Uses a custom solver service instead of the default HTTP service.
133    ///
134    /// This is useful for testing or when using a different transport.
135    pub fn with_service(mut self, service: Arc<dyn SolverService>) -> Self {
136        self.custom_service = Some(service);
137        self
138    }
139
140    /// Returns the domain model for the solution type.
141    ///
142    /// This is extracted from the `PlanningSolution::domain_model()` method.
143    pub fn domain_model() -> DomainModel {
144        S::domain_model()
145    }
146
147    /// Returns the constraint set for the solution type.
148    ///
149    /// This is extracted from the `PlanningSolution::constraints()` method.
150    pub fn constraints() -> ConstraintSet {
151        S::constraints()
152    }
153
154    /// Builds the solver configuration.
155    fn build_config(&self) -> SolverConfig {
156        let domain_model = S::domain_model();
157        let solution_class = domain_model.solution_class().map(|s| s.to_string());
158
159        let entity_classes: Vec<String> = domain_model
160            .classes
161            .values()
162            .filter(|c| c.is_planning_entity())
163            .map(|c| c.name.clone())
164            .collect();
165
166        let mut config = SolverConfig::new().with_entity_classes(entity_classes);
167
168        if let Some(solution_class) = solution_class {
169            config = config.with_solution_class(solution_class);
170        }
171
172        if let Some(termination) = &self.termination {
173            config = config.with_termination(termination.clone());
174        }
175
176        if let Some(mode) = &self.environment_mode {
177            config = config.with_environment_mode(*mode);
178        }
179
180        if let Some(seed) = self.random_seed {
181            config = config.with_random_seed(seed);
182        }
183
184        if let Some(count) = &self.move_thread_count {
185            config = config.with_move_thread_count(count.clone());
186        }
187
188        config
189    }
190
191    /// Generates the WASM module from the domain model.
192    ///
193    /// This builds a base64-encoded WASM module containing:
194    /// - Memory allocation functions
195    /// - Getters/setters for all domain class fields
196    /// - Predicate functions from constraints
197    fn generate_wasm_module(&self) -> SolverForgeResult<String> {
198        let domain_model = S::domain_model();
199        let constraints = S::constraints();
200
201        // Extract predicates from constraints (expressions that need to be compiled to WASM)
202        let predicates = constraints.extract_predicates();
203
204        let mut builder = WasmModuleBuilder::new()
205            .with_host_functions(HostFunctionRegistry::with_standard_functions())
206            .with_domain_model(domain_model);
207
208        // Add all constraint predicates to the WASM module
209        for predicate in predicates {
210            builder = builder.add_predicate(predicate);
211        }
212
213        builder.build_base64()
214    }
215
216    /// Builds a `TypedSolver` that can solve instances of the solution type.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if WASM module generation fails.
221    pub fn build<B: LanguageBridge>(self) -> SolverForgeResult<TypedSolver<S, B>> {
222        let config = self.build_config();
223        let domain_model = S::domain_model();
224        let constraints = S::constraints();
225        let wasm_module = self.generate_wasm_module()?;
226
227        let service: Arc<dyn SolverService> = match self.custom_service {
228            Some(service) => service,
229            None => Arc::new(HttpSolverService::new(&self.service_url)),
230        };
231
232        Ok(TypedSolver {
233            config,
234            domain_model,
235            constraints,
236            wasm_module,
237            service,
238            service_url: self.service_url,
239            _phantom: PhantomData,
240        })
241    }
242
243    /// Builds a `TypedSolver` with a specific bridge instance.
244    ///
245    /// This is a convenience method that creates a solver ready to use with
246    /// the provided bridge.
247    pub fn build_with_bridge<B: LanguageBridge>(
248        self,
249        _bridge: Arc<B>,
250    ) -> SolverForgeResult<TypedSolver<S, B>> {
251        self.build()
252    }
253}
254
255/// A solver that is typed to a specific `PlanningSolution` type.
256///
257/// This provides a type-safe interface for solving problems and extracting
258/// solutions of the correct type.
259pub struct TypedSolver<S: PlanningSolution, B: LanguageBridge> {
260    config: SolverConfig,
261    domain_model: DomainModel,
262    constraints: ConstraintSet,
263    wasm_module: String,
264    service: Arc<dyn SolverService>,
265    service_url: String,
266    _phantom: PhantomData<(S, B)>,
267}
268
269impl<S: PlanningSolution, B: LanguageBridge> TypedSolver<S, B> {
270    /// Returns the solver configuration.
271    pub fn config(&self) -> &SolverConfig {
272        &self.config
273    }
274
275    /// Returns the domain model.
276    pub fn domain_model(&self) -> &DomainModel {
277        &self.domain_model
278    }
279
280    /// Returns the constraint set.
281    pub fn constraints(&self) -> &ConstraintSet {
282        &self.constraints
283    }
284
285    /// Returns the generated WASM module as a base64-encoded string.
286    pub fn wasm_module(&self) -> &str {
287        &self.wasm_module
288    }
289
290    /// Returns the service URL.
291    pub fn service_url(&self) -> &str {
292        &self.service_url
293    }
294
295    /// Checks if the solver service is available.
296    pub fn is_service_available(&self) -> bool {
297        self.service.is_available()
298    }
299
300    /// Solves the given problem and returns the solution.
301    ///
302    /// # Arguments
303    ///
304    /// * `problem` - The initial solution with unassigned planning variables
305    ///
306    /// # Returns
307    ///
308    /// The solved solution with planning variables assigned, or an error
309    /// if solving fails.
310    pub fn solve(&self, problem: S) -> SolverForgeResult<S> {
311        // Serialize the problem to JSON
312        let problem_json = problem.to_json()?;
313
314        // Build the solve request
315        let domain_dto = self.domain_model.to_dto();
316        let constraints_dto = self.constraints.to_dto();
317
318        let list_accessor = crate::solver::ListAccessorDto::new(
319            "newList", "getItem", "setItem", "size", "append", "insert", "remove", "dealloc",
320        );
321
322        let mut request = crate::solver::SolveRequest::new(
323            domain_dto,
324            constraints_dto,
325            self.wasm_module.clone(),
326            "alloc".to_string(),
327            "dealloc".to_string(),
328            list_accessor,
329            problem_json,
330        );
331
332        if let Some(mode) = &self.config.environment_mode {
333            request = request.with_environment_mode(format!("{:?}", mode).to_uppercase());
334        }
335
336        if let Some(termination) = &self.config.termination {
337            request = request.with_termination(termination.clone());
338        }
339
340        // Solve
341        let response = self.service.solve(&request)?;
342
343        // Parse the solution from JSON
344        S::from_json(&response.solution)
345    }
346
347    /// Starts an asynchronous solve and returns a handle.
348    ///
349    /// Use `get_best_solution()` to retrieve intermediate solutions and
350    /// `stop()` to terminate early.
351    pub fn solve_async(&self, problem: S) -> SolverForgeResult<crate::solver::SolveHandle> {
352        let problem_json = problem.to_json()?;
353
354        let domain_dto = self.domain_model.to_dto();
355        let constraints_dto = self.constraints.to_dto();
356
357        let list_accessor = crate::solver::ListAccessorDto::new(
358            "newList", "getItem", "setItem", "size", "append", "insert", "remove", "dealloc",
359        );
360
361        let mut request = crate::solver::SolveRequest::new(
362            domain_dto,
363            constraints_dto,
364            self.wasm_module.clone(),
365            "alloc".to_string(),
366            "dealloc".to_string(),
367            list_accessor,
368            problem_json,
369        );
370
371        if let Some(mode) = &self.config.environment_mode {
372            request = request.with_environment_mode(format!("{:?}", mode).to_uppercase());
373        }
374
375        if let Some(termination) = &self.config.termination {
376            request = request.with_termination(termination.clone());
377        }
378
379        self.service.solve_async(&request)
380    }
381
382    /// Gets the status of an asynchronous solve.
383    pub fn get_status(
384        &self,
385        handle: &crate::solver::SolveHandle,
386    ) -> SolverForgeResult<crate::solver::SolveStatus> {
387        self.service.get_status(handle)
388    }
389
390    /// Gets the best solution found so far in an asynchronous solve.
391    pub fn get_best_solution(
392        &self,
393        handle: &crate::solver::SolveHandle,
394    ) -> SolverForgeResult<Option<S>> {
395        let response = self.service.get_best_solution(handle)?;
396        match response {
397            Some(r) => Ok(Some(S::from_json(&r.solution)?)),
398            None => Ok(None),
399        }
400    }
401
402    /// Stops an asynchronous solve.
403    pub fn stop(&self, handle: &crate::solver::SolveHandle) -> SolverForgeResult<()> {
404        self.service.stop(handle)
405    }
406}
407
408/// Error type for solver builder operations.
409#[derive(Debug, Clone)]
410pub enum SolverBuilderError {
411    /// WASM module generation failed
412    WasmGeneration(String),
413    /// Configuration is invalid
414    InvalidConfiguration(String),
415}
416
417impl std::fmt::Display for SolverBuilderError {
418    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
419        match self {
420            SolverBuilderError::WasmGeneration(msg) => {
421                write!(f, "WASM module generation failed: {}", msg)
422            }
423            SolverBuilderError::InvalidConfiguration(msg) => {
424                write!(f, "Invalid solver configuration: {}", msg)
425            }
426        }
427    }
428}
429
430impl std::error::Error for SolverBuilderError {}
431
432impl From<SolverBuilderError> for SolverForgeError {
433    fn from(err: SolverBuilderError) -> Self {
434        match err {
435            SolverBuilderError::WasmGeneration(msg) => SolverForgeError::WasmGeneration(msg),
436            SolverBuilderError::InvalidConfiguration(msg) => SolverForgeError::Configuration(msg),
437        }
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use crate::bridge::tests::MockBridge;
445    use crate::constraints::Constraint;
446    use crate::domain::{
447        DomainClass, FieldDescriptor, FieldType, PlanningAnnotation, PrimitiveType, ScoreType,
448    };
449    use crate::traits::PlanningEntity;
450    use crate::HardSoftScore;
451    use std::collections::HashMap;
452
453    // Test entity
454    #[derive(Clone, Debug)]
455    struct TestLesson {
456        id: String,
457        subject: String,
458        room: Option<String>,
459    }
460
461    impl crate::traits::PlanningEntity for TestLesson {
462        fn domain_class() -> crate::domain::DomainClass {
463            DomainClass::new("TestLesson")
464                .with_annotation(PlanningAnnotation::PlanningEntity)
465                .with_field(
466                    FieldDescriptor::new("id", FieldType::Primitive(PrimitiveType::String))
467                        .with_annotation(PlanningAnnotation::PlanningId),
468                )
469                .with_field(FieldDescriptor::new(
470                    "subject",
471                    FieldType::Primitive(PrimitiveType::String),
472                ))
473                .with_field(
474                    FieldDescriptor::new("room", FieldType::Primitive(PrimitiveType::String))
475                        .with_annotation(PlanningAnnotation::planning_variable(vec![
476                            "rooms".to_string()
477                        ])),
478                )
479        }
480
481        fn planning_id(&self) -> crate::Value {
482            crate::Value::String(self.id.clone())
483        }
484
485        fn to_value(&self) -> crate::Value {
486            let mut map = HashMap::new();
487            map.insert("id".to_string(), crate::Value::String(self.id.clone()));
488            map.insert(
489                "subject".to_string(),
490                crate::Value::String(self.subject.clone()),
491            );
492            map.insert(
493                "room".to_string(),
494                self.room
495                    .clone()
496                    .map(crate::Value::String)
497                    .unwrap_or(crate::Value::Null),
498            );
499            crate::Value::Object(map)
500        }
501
502        fn from_value(value: &crate::Value) -> SolverForgeResult<Self> {
503            match value {
504                crate::Value::Object(map) => {
505                    let id = map
506                        .get("id")
507                        .and_then(|v| v.as_str())
508                        .ok_or_else(|| SolverForgeError::Serialization("Missing id".to_string()))?
509                        .to_string();
510                    let subject = map
511                        .get("subject")
512                        .and_then(|v| v.as_str())
513                        .ok_or_else(|| {
514                            SolverForgeError::Serialization("Missing subject".to_string())
515                        })?
516                        .to_string();
517                    let room = map.get("room").and_then(|v| v.as_str()).map(String::from);
518                    Ok(TestLesson { id, subject, room })
519                }
520                _ => Err(SolverForgeError::Serialization(
521                    "Expected object".to_string(),
522                )),
523            }
524        }
525    }
526
527    // Test solution
528    #[derive(Clone, Debug)]
529    struct TestTimetable {
530        rooms: Vec<String>,
531        lessons: Vec<TestLesson>,
532        score: Option<HardSoftScore>,
533    }
534
535    impl PlanningSolution for TestTimetable {
536        type Score = HardSoftScore;
537
538        fn domain_model() -> DomainModel {
539            DomainModel::builder()
540                .add_class(TestLesson::domain_class())
541                .add_class(
542                    DomainClass::new("TestTimetable")
543                        .with_annotation(PlanningAnnotation::PlanningSolution)
544                        .with_field(
545                            FieldDescriptor::new(
546                                "rooms",
547                                FieldType::list(FieldType::Primitive(PrimitiveType::String)),
548                            )
549                            .with_annotation(PlanningAnnotation::ProblemFactCollectionProperty)
550                            .with_annotation(
551                                PlanningAnnotation::value_range_provider_with_id("rooms"),
552                            ),
553                        )
554                        .with_field(
555                            FieldDescriptor::new(
556                                "lessons",
557                                FieldType::list(FieldType::object("TestLesson")),
558                            )
559                            .with_annotation(PlanningAnnotation::PlanningEntityCollectionProperty),
560                        )
561                        .with_field(
562                            FieldDescriptor::new("score", FieldType::Score(ScoreType::HardSoft))
563                                .with_annotation(PlanningAnnotation::planning_score()),
564                        ),
565                )
566                .build()
567        }
568
569        fn constraints() -> ConstraintSet {
570            ConstraintSet::new().with_constraint(Constraint::new("Test constraint"))
571        }
572
573        fn score(&self) -> Option<Self::Score> {
574            self.score
575        }
576
577        fn set_score(&mut self, score: Self::Score) {
578            self.score = Some(score);
579        }
580
581        fn to_json(&self) -> SolverForgeResult<String> {
582            let mut map = HashMap::new();
583
584            let rooms: Vec<crate::Value> = self
585                .rooms
586                .iter()
587                .map(|r| crate::Value::String(r.clone()))
588                .collect();
589            map.insert("rooms".to_string(), crate::Value::Array(rooms));
590
591            let lessons: Vec<crate::Value> = self.lessons.iter().map(|l| l.to_value()).collect();
592            map.insert("lessons".to_string(), crate::Value::Array(lessons));
593
594            if let Some(score) = &self.score {
595                map.insert(
596                    "score".to_string(),
597                    crate::Value::String(format!("{}", score)),
598                );
599            }
600
601            serde_json::to_string(&crate::Value::Object(map))
602                .map_err(|e| SolverForgeError::Serialization(e.to_string()))
603        }
604
605        fn from_json(json: &str) -> SolverForgeResult<Self> {
606            let value: crate::Value = serde_json::from_str(json)
607                .map_err(|e| SolverForgeError::Serialization(e.to_string()))?;
608
609            match value {
610                crate::Value::Object(map) => {
611                    let rooms = match map.get("rooms") {
612                        Some(crate::Value::Array(arr)) => arr
613                            .iter()
614                            .filter_map(|v| v.as_str().map(String::from))
615                            .collect(),
616                        _ => Vec::new(),
617                    };
618
619                    let lessons = match map.get("lessons") {
620                        Some(crate::Value::Array(arr)) => arr
621                            .iter()
622                            .filter_map(|v| TestLesson::from_value(v).ok())
623                            .collect(),
624                        _ => Vec::new(),
625                    };
626
627                    Ok(TestTimetable {
628                        rooms,
629                        lessons,
630                        score: None,
631                    })
632                }
633                _ => Err(SolverForgeError::Serialization(
634                    "Expected object".to_string(),
635                )),
636            }
637        }
638    }
639
640    #[test]
641    fn test_solver_builder_new() {
642        let builder = SolverBuilder::<TestTimetable>::new();
643        assert_eq!(builder.service_url, DEFAULT_SERVICE_URL);
644        assert!(builder.termination.is_none());
645        assert!(builder.environment_mode.is_none());
646    }
647
648    #[test]
649    fn test_solver_builder_default() {
650        let builder = SolverBuilder::<TestTimetable>::default();
651        assert_eq!(builder.service_url, DEFAULT_SERVICE_URL);
652    }
653
654    #[test]
655    fn test_solver_builder_with_service_url() {
656        let builder = SolverBuilder::<TestTimetable>::new().with_service_url("http://custom:9000");
657        assert_eq!(builder.service_url, "http://custom:9000");
658    }
659
660    #[test]
661    fn test_solver_builder_with_termination() {
662        let termination = TerminationConfig::new().with_spent_limit("PT5M");
663        let builder = SolverBuilder::<TestTimetable>::new().with_termination(termination.clone());
664        assert_eq!(builder.termination, Some(termination));
665    }
666
667    #[test]
668    fn test_solver_builder_with_environment_mode() {
669        let builder = SolverBuilder::<TestTimetable>::new()
670            .with_environment_mode(EnvironmentMode::FullAssert);
671        assert_eq!(builder.environment_mode, Some(EnvironmentMode::FullAssert));
672    }
673
674    #[test]
675    fn test_solver_builder_with_random_seed() {
676        let builder = SolverBuilder::<TestTimetable>::new().with_random_seed(42);
677        assert_eq!(builder.random_seed, Some(42));
678    }
679
680    #[test]
681    fn test_solver_builder_with_move_thread_count() {
682        let builder =
683            SolverBuilder::<TestTimetable>::new().with_move_thread_count(MoveThreadCount::Auto);
684        assert_eq!(builder.move_thread_count, Some(MoveThreadCount::Auto));
685    }
686
687    #[test]
688    fn test_solver_builder_domain_model() {
689        let model = SolverBuilder::<TestTimetable>::domain_model();
690        assert!(model.get_solution_class().is_some());
691        assert_eq!(model.get_solution_class().unwrap().name, "TestTimetable");
692        assert!(model.get_class("TestLesson").is_some());
693    }
694
695    #[test]
696    fn test_solver_builder_constraints() {
697        let constraints = SolverBuilder::<TestTimetable>::constraints();
698        assert!(!constraints.is_empty());
699    }
700
701    #[test]
702    fn test_solver_builder_build_config() {
703        let builder = SolverBuilder::<TestTimetable>::new()
704            .with_termination(TerminationConfig::new().with_spent_limit("PT5M"))
705            .with_environment_mode(EnvironmentMode::Reproducible)
706            .with_random_seed(42);
707
708        let config = builder.build_config();
709        assert_eq!(config.solution_class, Some("TestTimetable".to_string()));
710        assert!(config.entity_class_list.contains(&"TestLesson".to_string()));
711        assert_eq!(config.environment_mode, Some(EnvironmentMode::Reproducible));
712        assert_eq!(config.random_seed, Some(42));
713        assert!(config.termination.is_some());
714    }
715
716    #[test]
717    fn test_solver_builder_generate_wasm() {
718        let builder = SolverBuilder::<TestTimetable>::new();
719        let wasm = builder.generate_wasm_module().unwrap();
720        assert!(wasm.starts_with("AGFzbQ")); // Base64 of "\0asm"
721    }
722
723    #[test]
724    fn test_solver_builder_build() {
725        let solver = SolverBuilder::<TestTimetable>::new()
726            .with_service_url("http://localhost:19999")
727            .with_termination(TerminationConfig::new().with_spent_limit("PT1M"))
728            .build::<MockBridge>()
729            .unwrap();
730
731        assert_eq!(
732            solver.config().solution_class,
733            Some("TestTimetable".to_string())
734        );
735        assert!(solver.wasm_module().starts_with("AGFzbQ"));
736        assert_eq!(solver.service_url(), "http://localhost:19999");
737    }
738
739    #[test]
740    fn test_solver_builder_chained() {
741        let solver = SolverBuilder::<TestTimetable>::new()
742            .with_service_url("http://test:8080")
743            .with_termination(TerminationConfig::new().with_spent_limit("PT10M"))
744            .with_environment_mode(EnvironmentMode::NoAssert)
745            .with_random_seed(123)
746            .with_move_thread_count(MoveThreadCount::Count(4))
747            .build::<MockBridge>()
748            .unwrap();
749
750        let config = solver.config();
751        assert_eq!(config.environment_mode, Some(EnvironmentMode::NoAssert));
752        assert_eq!(config.random_seed, Some(123));
753        assert_eq!(config.move_thread_count, Some(MoveThreadCount::Count(4)));
754    }
755
756    #[test]
757    fn test_typed_solver_domain_model() {
758        let solver = SolverBuilder::<TestTimetable>::new()
759            .build::<MockBridge>()
760            .unwrap();
761
762        let model = solver.domain_model();
763        assert!(model.get_solution_class().is_some());
764    }
765
766    #[test]
767    fn test_typed_solver_constraints() {
768        let solver = SolverBuilder::<TestTimetable>::new()
769            .build::<MockBridge>()
770            .unwrap();
771
772        let constraints = solver.constraints();
773        assert!(!constraints.is_empty());
774    }
775
776    #[test]
777    fn test_typed_solver_is_service_available_offline() {
778        let solver = SolverBuilder::<TestTimetable>::new()
779            .with_service_url("http://localhost:19999")
780            .build::<MockBridge>()
781            .unwrap();
782
783        // Service at port 19999 should not be available
784        assert!(!solver.is_service_available());
785    }
786
787    #[test]
788    fn test_solver_builder_error_display() {
789        let err = SolverBuilderError::WasmGeneration("test error".to_string());
790        assert!(err.to_string().contains("WASM module generation failed"));
791
792        let err = SolverBuilderError::InvalidConfiguration("invalid".to_string());
793        assert!(err.to_string().contains("Invalid solver configuration"));
794    }
795
796    #[test]
797    fn test_solver_builder_error_conversion() {
798        let err = SolverBuilderError::WasmGeneration("test".to_string());
799        let forge_err: SolverForgeError = err.into();
800        assert!(matches!(forge_err, SolverForgeError::WasmGeneration(_)));
801
802        let err = SolverBuilderError::InvalidConfiguration("test".to_string());
803        let forge_err: SolverForgeError = err.into();
804        assert!(matches!(forge_err, SolverForgeError::Configuration(_)));
805    }
806}