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