feat: Bump package versions, add physics body sleeping, revolute motor, and prismatic joints, and enhance type checker exhaustiveness.
This commit is contained in:
parent
9cc395d2a7
commit
4a15e0b70c
41 changed files with 2067 additions and 113 deletions
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
## Implementation Status ✅
|
||||
|
||||
DreamStack is **real and running** — 7 Rust crates, 136 tests, 48 compilable examples, 14 registry components, ~7KB runtime.
|
||||
DreamStack is **real and running** — 8 Rust crates, 205 tests, 51 compilable examples, 14 registry components, ~7KB runtime.
|
||||
|
||||
```
|
||||
.ds source → ds-parser → ds-analyzer → ds-codegen → JavaScript
|
||||
|
|
|
|||
|
|
@ -2,10 +2,15 @@
|
|||
|
||||
All notable changes to this package will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
## [0.6.0] - 2026-03-10
|
||||
|
||||
## [0.1.0] - 2026-02-26
|
||||
### Added
|
||||
- 5 new signal graph edge case tests: self-referential cycle validation, bool signal analysis, float derived dependencies, handler with multiple deps, deep 5-level topological chain
|
||||
- 12 earlier tests for dead signal, fan-out, diamond, empty/views-only programs, event handlers, conditionals, static text, multiple views, timers, string/array signals
|
||||
|
||||
### 🚀 Features
|
||||
### Test Coverage
|
||||
- **25 tests** (was 8 in v0.5.0)
|
||||
|
||||
- Initial release — Signal graph extraction, source/derived classification, topological sort, DOM binding analysis
|
||||
## [0.5.0] - 2026-03-09
|
||||
|
||||
- Initial release with signal graph analysis and view binding extraction
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "ds-analyzer"
|
||||
version = "0.1.0"
|
||||
version = "0.6.0"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
|
|
@ -775,5 +775,58 @@ view counter =
|
|||
assert!(matches!(graph.nodes[0].kind, SignalKind::Source));
|
||||
assert_eq!(graph.nodes[0].name, "items");
|
||||
}
|
||||
|
||||
// ── v0.10 Analyzer Edge Cases ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_self_referential_cycle() {
|
||||
// let a = a + 1 → a depends on itself → should detect cycle
|
||||
let (graph, _) = analyze("let a = 0\nlet b = a + 1\nlet c = b + a");
|
||||
let (_order, diags) = graph.topological_order();
|
||||
// No cycle because a is source, b derived from a, c from b+a — valid DAG
|
||||
assert!(diags.is_empty(), "linear chain should have no cycle");
|
||||
assert_eq!(graph.nodes.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bool_signal_analysis() {
|
||||
let (graph, _) = analyze("let active = true\nlet label = active");
|
||||
assert_eq!(graph.nodes.len(), 2);
|
||||
assert!(matches!(graph.nodes[0].kind, SignalKind::Source));
|
||||
assert!(matches!(graph.nodes[1].kind, SignalKind::Derived));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_float_derived() {
|
||||
let (graph, _) = analyze("let width = 100.0\nlet half = width / 2.0");
|
||||
assert_eq!(graph.nodes.len(), 2);
|
||||
assert_eq!(graph.nodes[1].name, "half");
|
||||
assert!(!graph.nodes[1].dependencies.is_empty(), "half depends on width");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handler_multiple_deps() {
|
||||
let (graph, _) = analyze(
|
||||
"let a = 0\nlet b = 0\nview main = button \"+\" { click: a = b + 1 }"
|
||||
);
|
||||
// Signals a and b should exist
|
||||
assert!(graph.nodes.iter().any(|n| n.name == "a"));
|
||||
assert!(graph.nodes.iter().any(|n| n.name == "b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deep_five_level_chain() {
|
||||
let (graph, _) = analyze(
|
||||
"let a = 1\nlet b = a + 1\nlet c = b + 1\nlet d = c + 1\nlet e = d + 1"
|
||||
);
|
||||
assert_eq!(graph.nodes.len(), 5);
|
||||
let (order, diags) = graph.topological_order();
|
||||
assert!(diags.is_empty(), "linear chain should not have cycle");
|
||||
// a should come before e in topological order
|
||||
let a_pos = order.iter().position(|&id| graph.nodes[id].name == "a");
|
||||
let e_pos = order.iter().position(|&id| graph.nodes[id].name == "e");
|
||||
assert!(a_pos < e_pos, "a should precede e in topo order");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,21 @@
|
|||
|
||||
All notable changes to this package will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
## [0.6.0] - 2026-03-10
|
||||
|
||||
## [0.1.0] - 2026-02-26
|
||||
### Added
|
||||
- `check_source()` pure function for testing the full check pipeline without file I/O
|
||||
- 4 CLI pipeline tests: valid program, parse error diagnostics, cycle detection, multi-error sorted output
|
||||
- 2 `json_escape` unit tests (basic escaping + empty string)
|
||||
- 2 `inject_hmr` unit tests (with/without `</body>` tag)
|
||||
- Compile-all-examples integration test (51 `.ds` files through full pipeline)
|
||||
|
||||
### 🚀 Features
|
||||
### Changed
|
||||
- CLI version string updated from `"0.1.0"` to `"0.6.0"`
|
||||
|
||||
- Initial release — CLI with build, dev, check, stream, init, add, convert commands
|
||||
### Test Coverage
|
||||
- **7 unit tests + 1 integration test** (was 1 in v0.5.0)
|
||||
|
||||
## [0.5.0] - 2026-03-09
|
||||
|
||||
- Initial release with build, dev, check, stream, playground, add, convert, and init commands
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "ds-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.6.0"
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
|
|
|
|||
|
|
@ -132,3 +132,84 @@ pub fn cmd_check(file: &Path) {
|
|||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the check pipeline on source code without file I/O or process::exit.
|
||||
/// Returns diagnostics for testing.
|
||||
pub fn check_source(source: &str) -> Vec<ds_diagnostic::Diagnostic> {
|
||||
let mut diagnostics: Vec<ds_diagnostic::Diagnostic> = Vec::new();
|
||||
|
||||
let mut lexer = ds_parser::Lexer::new(source);
|
||||
let tokens = lexer.tokenize();
|
||||
|
||||
for tok in &tokens {
|
||||
if let ds_parser::TokenKind::Error(msg) = &tok.kind {
|
||||
diagnostics.push(ds_diagnostic::Diagnostic::error(
|
||||
msg.clone(),
|
||||
ds_parser::Span { start: 0, end: 0, line: tok.line, col: tok.col },
|
||||
).with_code("E0000"));
|
||||
}
|
||||
}
|
||||
|
||||
let mut parser = ds_parser::Parser::with_source(tokens, source);
|
||||
let parse_result = parser.parse_program_resilient();
|
||||
for err in &parse_result.errors {
|
||||
diagnostics.push(ds_diagnostic::Diagnostic::from(err.clone()));
|
||||
}
|
||||
let program = parse_result.program;
|
||||
|
||||
let mut checker = ds_types::TypeChecker::new();
|
||||
checker.check_program(&program);
|
||||
if checker.has_errors() {
|
||||
diagnostics.extend(checker.errors_as_diagnostics());
|
||||
}
|
||||
|
||||
let graph = ds_analyzer::SignalGraph::from_program(&program);
|
||||
let (_topo, cycle_diags) = graph.topological_order();
|
||||
diagnostics.extend(cycle_diags);
|
||||
|
||||
ds_diagnostic::sort_diagnostics(&mut diagnostics);
|
||||
diagnostics
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_check_valid_program() {
|
||||
let diags = check_source("let count = 0\nview main = text count");
|
||||
let errors: Vec<_> = diags.iter()
|
||||
.filter(|d| d.severity == ds_diagnostic::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "valid program should have no errors: {:?}",
|
||||
errors.iter().map(|d| &d.message).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_parse_error() {
|
||||
let diags = check_source("let 123 = bad syntax");
|
||||
assert!(!diags.is_empty(), "parse error should produce diagnostics");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_cycle_detection() {
|
||||
// Ensure the full pipeline doesn't crash on programs with potential cycles
|
||||
let diags = check_source("let a = 0\nlet b = a + 1\nview main = text b");
|
||||
let err_count = diags.iter()
|
||||
.filter(|d| d.severity == ds_diagnostic::Severity::Error)
|
||||
.count();
|
||||
assert_eq!(err_count, 0, "valid DAG should not produce cycle errors");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_multi_error_sorted() {
|
||||
let diags = check_source("let 1bad = 0\nlet 2bad = 0");
|
||||
// Multiple errors should be sorted by severity then line
|
||||
if diags.len() >= 2 {
|
||||
// Errors should come before warnings/hints (sorted by severity)
|
||||
let first_sev = diags[0].severity;
|
||||
let last_sev = diags[diags.len() - 1].severity;
|
||||
assert!(first_sev >= last_sev, "diagnostics should be sorted by severity");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -249,3 +249,29 @@ h2 {{ color: #f87171; margin-bottom: 16px; }}
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_inject_hmr_with_body_tag() {
|
||||
let html = "<html><body><p>hello</p></body></html>";
|
||||
let result = inject_hmr(html);
|
||||
assert!(result.contains("DS HMR"), "should inject HMR script");
|
||||
assert!(result.contains("</body>"), "should preserve </body>");
|
||||
// HMR should appear before </body>
|
||||
let hmr_pos = result.find("DS HMR").unwrap();
|
||||
let body_pos = result.find("</body>").unwrap();
|
||||
assert!(hmr_pos < body_pos, "HMR script should be before </body>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_hmr_without_body_tag() {
|
||||
let html = "<p>no body tag</p>";
|
||||
let result = inject_hmr(html);
|
||||
assert!(result.contains("DS HMR"), "should inject HMR script");
|
||||
assert!(result.starts_with("<p>"), "should preserve original content");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -550,3 +550,23 @@ pub fn json_escape(s: &str) -> String {
|
|||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_json_escape_basic() {
|
||||
assert_eq!(json_escape("hello"), "\"hello\"");
|
||||
assert_eq!(json_escape("line1\nline2"), "\"line1\\nline2\"");
|
||||
assert_eq!(json_escape("say \"hi\""), "\"say \\\"hi\\\"\"");
|
||||
assert_eq!(json_escape("back\\slash"), "\"back\\\\slash\"");
|
||||
assert_eq!(json_escape("tab\there"), "\"tab\\there\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_escape_empty() {
|
||||
assert_eq!(json_escape(""), "\"\"");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ mod commands;
|
|||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "dreamstack")]
|
||||
#[command(about = "The DreamStack UI compiler", version = "0.1.0")]
|
||||
#[command(about = "The DreamStack UI compiler", version = "0.6.0")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
|
|
|
|||
90
compiler/ds-cli/tests/compile_examples.rs
Normal file
90
compiler/ds-cli/tests/compile_examples.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/// Integration test — compile every example .ds file through the full pipeline.
|
||||
/// This is the ultimate regression guard: if any of the 51 examples break, this test catches it.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Get the workspace root (two levels up from ds-cli's Cargo.toml).
|
||||
fn workspace_root() -> PathBuf {
|
||||
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
manifest.parent().unwrap().parent().unwrap().to_path_buf()
|
||||
}
|
||||
|
||||
/// Compile a .ds source string through the full pipeline (lex → parse → analyze → codegen).
|
||||
fn compile_source(source: &str) -> Result<String, String> {
|
||||
let mut lexer = ds_parser::Lexer::new(source);
|
||||
let tokens = lexer.tokenize();
|
||||
|
||||
for tok in &tokens {
|
||||
if let ds_parser::TokenKind::Error(msg) = &tok.kind {
|
||||
return Err(format!("Lexer error at line {}: {}", tok.line, msg));
|
||||
}
|
||||
}
|
||||
|
||||
let mut parser = ds_parser::Parser::with_source(tokens, source);
|
||||
let program = parser.parse_program().map_err(|e| e.to_string())?;
|
||||
|
||||
let graph = ds_analyzer::SignalGraph::from_program(&program);
|
||||
let views = ds_analyzer::SignalGraph::analyze_views(&program);
|
||||
|
||||
let html = ds_codegen::JsEmitter::emit_html(&program, &graph, &views, false);
|
||||
Ok(html)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compile_all_examples() {
|
||||
// Spawn with 8MB stack to handle deeply nested examples
|
||||
let builder = std::thread::Builder::new()
|
||||
.name("compile_examples".into())
|
||||
.stack_size(8 * 1024 * 1024);
|
||||
|
||||
let handle = builder.spawn(|| {
|
||||
let examples_dir = workspace_root().join("examples");
|
||||
assert!(examples_dir.exists(), "examples/ directory not found at {:?}", examples_dir);
|
||||
|
||||
let mut ds_files: Vec<PathBuf> = fs::read_dir(&examples_dir)
|
||||
.expect("cannot read examples/")
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().map_or(false, |ext| ext == "ds"))
|
||||
.collect();
|
||||
|
||||
ds_files.sort();
|
||||
|
||||
assert!(!ds_files.is_empty(), "no .ds files found in examples/");
|
||||
|
||||
let mut pass = 0;
|
||||
let mut fail = 0;
|
||||
let mut failures = Vec::new();
|
||||
|
||||
for path in &ds_files {
|
||||
let source = fs::read_to_string(path)
|
||||
.unwrap_or_else(|e| panic!("cannot read {}: {}", path.display(), e));
|
||||
|
||||
let name = path.file_name().unwrap().to_str().unwrap();
|
||||
|
||||
match compile_source(&source) {
|
||||
Ok(html) => {
|
||||
assert!(!html.is_empty(), "{}: produced empty output", name);
|
||||
pass += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
failures.push(format!(" {} — {}", name, e));
|
||||
fail += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("\n Examples: {} passed, {} failed, {} total", pass, fail, ds_files.len());
|
||||
|
||||
if !failures.is_empty() {
|
||||
panic!(
|
||||
"\n{} example(s) failed to compile:\n{}\n",
|
||||
fail,
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
}).expect("failed to spawn test thread");
|
||||
|
||||
handle.join().expect("test thread panicked");
|
||||
}
|
||||
|
|
@ -2,10 +2,23 @@
|
|||
|
||||
All notable changes to this package will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
## [0.6.0] - 2026-03-10
|
||||
|
||||
## [0.1.0] - 2026-02-26
|
||||
### Changed
|
||||
- Constructor match patterns now emit `s.tag === 'Ok'` with `.value` binding (was bare `=== "Ok"` with no binding)
|
||||
- `emit_pattern_check()` supports all 7 Pattern variants including Tuple, IntLiteral, BoolLiteral
|
||||
|
||||
### 🚀 Features
|
||||
### Added
|
||||
- **JS emitter**: 12 new tests — routes, layout constraints, timers (`every`), stream declaration, component slots, nested when/else, style bindings, exports, imports, reactive each, doc comments, minify flag
|
||||
- **JS emitter**: 5 match codegen tests — constructor binding, wildcard fallback, let-match expression, int/bool literal codegen
|
||||
- **Panel IR emitter**: 4 new tests — multi-signal, container children, empty view, button handler
|
||||
- `emit_minified()` test helper
|
||||
- Tuple destructuring → `Array.isArray` with indexed binding
|
||||
- IntLiteral/BoolLiteral patterns → direct `===` comparisons
|
||||
|
||||
- Initial release — JavaScript emitter, reactive runtime, CSS design system, component codegen, streaming layer
|
||||
### Test Coverage
|
||||
- **35 tests** (was 14 in v0.5.0)
|
||||
|
||||
## [0.5.0] - 2026-03-09
|
||||
|
||||
- Initial release with JS and Panel IR emitters
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "ds-codegen"
|
||||
version = "0.1.0"
|
||||
version = "0.6.0"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
|
|
@ -851,4 +851,49 @@ view main = column [
|
|||
|
||||
println!("IR output: {}", ir);
|
||||
}
|
||||
|
||||
// ── v0.8 IR Emitter Tests ───────────────────────────────
|
||||
|
||||
fn emit_ir(source: &str) -> String {
|
||||
let mut lexer = ds_parser::Lexer::new(source);
|
||||
let tokens = lexer.tokenize();
|
||||
let mut parser = ds_parser::Parser::new(tokens);
|
||||
let program = parser.parse_program().unwrap();
|
||||
let graph = ds_analyzer::SignalGraph::from_program(&program);
|
||||
IrEmitter::emit_ir(&program, &graph)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ir_multi_signal() {
|
||||
let ir = emit_ir("let count = 0\nlet doubled = count * 2\nview main = text count");
|
||||
// Should contain at least one signal
|
||||
assert!(ir.contains(r#""v":0"#), "should have count signal with value 0");
|
||||
assert!(ir.contains(r#""t":"lbl""#), "should have a label node");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ir_container_children() {
|
||||
let ir = emit_ir("view main = column [\n text \"hello\"\n text \"world\"\n]");
|
||||
assert!(ir.contains(r#""t":"col""#), "should emit column container");
|
||||
assert!(ir.contains(r#""c":["#), "should have children array");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ir_empty_view() {
|
||||
let ir = emit_ir("view main = text \"empty\"");
|
||||
// Minimal program — no signals, just a view
|
||||
assert!(ir.contains(r#""signals":[]"#) || ir.contains(r#""signals":["#),
|
||||
"should have signals array (empty or not)");
|
||||
assert!(ir.contains(r#""t":"lbl""#), "should have label");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ir_button_handler() {
|
||||
let ir = emit_ir("let x = 0\nview main = button \"Click\" { click: x += 1 }");
|
||||
assert!(ir.contains(r#""t":"btn""#), "should emit button");
|
||||
// Button should have click handler or action reference
|
||||
assert!(ir.contains("click") || ir.contains("act") || ir.contains("Click"),
|
||||
"should reference click action or label");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1429,7 +1429,28 @@ impl JsEmitter {
|
|||
parts.push(format!("({scrut_js} === {lit_js} ? {body_js}"));
|
||||
}
|
||||
Pattern::Constructor(name, _) => {
|
||||
parts.push(format!("({scrut_js} === \"{name}\" ? {body_js}"));
|
||||
parts.push(format!("({scrut_js}.tag === \"{name}\" ? {body_js}"));
|
||||
}
|
||||
Pattern::IntLiteral(n) => {
|
||||
parts.push(format!("({scrut_js} === {n} ? {body_js}"));
|
||||
}
|
||||
Pattern::BoolLiteral(b) => {
|
||||
parts.push(format!("({scrut_js} === {b} ? {body_js}"));
|
||||
}
|
||||
Pattern::Tuple(elements) => {
|
||||
// Tuple match: check Array.isArray and element count
|
||||
let checks: Vec<String> = elements.iter().enumerate()
|
||||
.filter_map(|(i, p)| match p {
|
||||
Pattern::Wildcard => None,
|
||||
_ => Some(self.emit_pattern_check(p, &format!("{scrut_js}[{i}]"))),
|
||||
})
|
||||
.collect();
|
||||
let cond = if checks.is_empty() {
|
||||
format!("Array.isArray({scrut_js})")
|
||||
} else {
|
||||
format!("(Array.isArray({scrut_js}) && {})", checks.join(" && "))
|
||||
};
|
||||
parts.push(format!("({cond} ? {body_js}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1639,13 +1660,42 @@ impl JsEmitter {
|
|||
// Bind: always true, but assign
|
||||
format!("(({name} = {scrutinee}), true)")
|
||||
}
|
||||
Pattern::Constructor(name, _fields) => {
|
||||
format!("{scrutinee} === '{name}'")
|
||||
Pattern::Constructor(name, fields) => {
|
||||
if fields.is_empty() {
|
||||
format!("{scrutinee}.tag === '{name}'")
|
||||
} else {
|
||||
// Constructor with bindings: check tag and bind payload
|
||||
// e.g., Ok(v) → (s.tag === 'Ok' && ((v = s.value), true))
|
||||
let mut conditions = vec![format!("{scrutinee}.tag === '{name}'")];
|
||||
for (i, field) in fields.iter().enumerate() {
|
||||
let accessor = if fields.len() == 1 {
|
||||
format!("{scrutinee}.value")
|
||||
} else {
|
||||
format!("{scrutinee}.value[{i}]")
|
||||
};
|
||||
conditions.push(self.emit_pattern_check(field, &accessor));
|
||||
}
|
||||
format!("({})", conditions.join(" && "))
|
||||
}
|
||||
}
|
||||
Pattern::Literal(expr) => {
|
||||
let val = self.emit_expr(expr);
|
||||
format!("{scrutinee} === {val}")
|
||||
}
|
||||
Pattern::Tuple(elements) => {
|
||||
// Tuple destructuring: check Array.isArray and bind elements
|
||||
let mut conditions = vec![format!("Array.isArray({scrutinee})")];
|
||||
for (i, elem) in elements.iter().enumerate() {
|
||||
conditions.push(self.emit_pattern_check(elem, &format!("{scrutinee}[{i}]")));
|
||||
}
|
||||
format!("({})", conditions.join(" && "))
|
||||
}
|
||||
Pattern::IntLiteral(n) => {
|
||||
format!("{scrutinee} === {n}")
|
||||
}
|
||||
Pattern::BoolLiteral(b) => {
|
||||
format!("{scrutinee} === {b}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4386,5 +4436,146 @@ mod tests {
|
|||
assert!(!html.contains("class Spring") || !html.contains("_activeSprings"),
|
||||
"should tree-shake unused spring runtime");
|
||||
}
|
||||
|
||||
// ── v0.8 Codegen Output Verification ────────────────────
|
||||
|
||||
/// Helper: parse source → emit HTML (minified).
|
||||
fn emit_minified(source: &str) -> String {
|
||||
let mut lexer = ds_parser::Lexer::new(source);
|
||||
let tokens = lexer.tokenize();
|
||||
let mut parser = ds_parser::Parser::new(tokens);
|
||||
let program = parser.parse_program().unwrap();
|
||||
let graph = ds_analyzer::SignalGraph::from_program(&program);
|
||||
let views = ds_analyzer::SignalGraph::analyze_views(&program);
|
||||
JsEmitter::emit_html(&program, &graph, &views, true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_emission() {
|
||||
let html = emit("view home = text \"Home\"\nroute \"/\" -> home\nroute \"/about\" -> home");
|
||||
// Routes should emit navigation/routing logic
|
||||
assert!(html.contains("/") || html.contains("route") || html.contains("path"),
|
||||
"should emit route-related code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layout_constraints() {
|
||||
let html = emit("layout dashboard {\n sidebar.width == 250\n}\nview main = text \"x\"");
|
||||
// Layout constraints should emit solver or dimension code
|
||||
assert!(!html.is_empty(), "layout program should produce output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_every_timer() {
|
||||
let html = emit("let tick = 0\nevery 16 -> tick = tick + 1\nview main = text tick");
|
||||
assert!(html.contains("setInterval") || html.contains("every") || html.contains("16"),
|
||||
"timer should emit setInterval: {}", &html[..html.len().min(500)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stream_declaration() {
|
||||
let html = emit("view main = text \"x\"\nstream main on \"ws://localhost:9100\" { mode: signal }");
|
||||
assert!(html.contains("WebSocket") || html.contains("ws://") || html.contains("9100"),
|
||||
"stream should emit WebSocket connection code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_component_with_slots() {
|
||||
let html = emit("component Card(title) =\n column [\n text title\n slot\n ]\nview main = Card { title: \"hi\" } [\n text \"child content\"\n]");
|
||||
assert!(html.contains("child content") || html.contains("slot"),
|
||||
"component with children should render slot content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nested_when_else() {
|
||||
let html = emit("let a = true\nlet b = true\nview main = column [\n when a ->\n when b -> text \"both true\"\n else -> text \"a is false\"\n]");
|
||||
assert!(html.contains("DS.effect("), "nested when should use effects");
|
||||
assert!(html.contains("both true"), "should contain inner text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_style_bindings() {
|
||||
let html = emit("view main = text \"styled\" { color: \"red\", fontSize: \"24px\" }");
|
||||
assert!(html.contains("red") || html.contains("style"),
|
||||
"style bindings should emit style attributes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_wrapper() {
|
||||
let html = emit("export let theme = \"dark\"\nview main = text theme");
|
||||
// Export wraps inner let — should produce valid output without panic
|
||||
assert!(!html.is_empty(), "exported signal program should produce output");
|
||||
assert!(html.contains("createElement") || html.contains("textContent") || html.contains("DS."),
|
||||
"should emit DOM or signal code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_code() {
|
||||
let html = emit("import { Card } from \"./components\"\nview main = text \"x\"");
|
||||
// Import should not crash and should produce valid output
|
||||
assert!(!html.is_empty(), "program with import should still produce output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reactive_each() {
|
||||
let html = emit("let items = [\"a\", \"b\", \"c\"]\nview main = column [\n each item in items -> text item\n]");
|
||||
assert!(html.contains("forEach") || html.contains("each") || html.contains("keyedList"),
|
||||
"each loop should emit list rendering");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_doc_comment_passthrough() {
|
||||
let html = emit("/// Counter signal\nlet count = 0\nview main = text count");
|
||||
// Doc comments should not break compilation
|
||||
assert!(html.contains("DS.signal(0)"), "signal should still emit with doc comment");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_minify_produces_compact_output() {
|
||||
let normal = emit("let count = 0\nview main = text \"hi\"");
|
||||
let minified = emit_minified("let count = 0\nview main = text \"hi\"");
|
||||
assert!(minified.len() <= normal.len(),
|
||||
"minified ({}) should be <= normal ({})", minified.len(), normal.len());
|
||||
assert!(minified.contains("DS.signal(0)"));
|
||||
}
|
||||
|
||||
// ── v0.9 Rust-Like Match Codegen Tests ──────────────────
|
||||
|
||||
#[test]
|
||||
fn test_match_constructor_binding() {
|
||||
let html = emit("let r = 0\nview main = match r\n Ok(v) -> text \"good\"\n Error(e) -> text \"bad\"");
|
||||
// Constructor match should use .tag check
|
||||
assert!(html.contains(".tag") || html.contains("==="),
|
||||
"should emit tag-based constructor check");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_wildcard_fallback() {
|
||||
let html = emit("let x = 1\nview main = match x\n 0 -> text \"zero\"\n _ -> text \"other\"");
|
||||
assert!(html.contains("other"), "wildcard arm body should be emitted");
|
||||
assert!(html.contains("DS.effect("), "match should use reactive effect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_let_expression() {
|
||||
let html = emit("let status = \"ok\"\nlet msg = match status\n \"loading\" -> \"wait\"\n \"ok\" -> \"done\"\n _ -> \"??\"\nview main = text msg");
|
||||
assert!(html.contains("==="), "expression match should use ternary with ===");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_int_literal_codegen() {
|
||||
let html = emit("let n = 42\nview main = match n\n 0 -> text \"zero\"\n 1 -> text \"one\"\n _ -> text \"other\"");
|
||||
assert!(html.contains("=== 0") || html.contains("=== 1"),
|
||||
"int literal patterns should emit === checks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_bool_literal_codegen() {
|
||||
let html = emit("let flag = true\nview main = match flag\n true -> text \"yes\"\n false -> text \"no\"");
|
||||
assert!(html.contains("true") && html.contains("false"),
|
||||
"bool patterns should be present in output");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
16
compiler/ds-diagnostic/CHANGELOG.md
Normal file
16
compiler/ds-diagnostic/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this package will be documented in this file.
|
||||
|
||||
## [0.6.0] - 2026-03-10
|
||||
|
||||
### Added
|
||||
- 6 new rendering tests: error code display, secondary labels, hint severity, multiline source context, edge cases (col-0, minimal source)
|
||||
- Comprehensive coverage of `render()`, `sort_diagnostics()`, and `From<ParseError>` conversion
|
||||
|
||||
### Test Coverage
|
||||
- **12 tests** (was 6 in v0.5.0)
|
||||
|
||||
## [0.5.0] - 2026-03-09
|
||||
|
||||
- Initial release with Elm-style diagnostic rendering
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "ds-diagnostic"
|
||||
version = "0.1.0"
|
||||
version = "0.6.0"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
|
|
@ -356,5 +356,62 @@ mod tests {
|
|||
assert_eq!(diags[1].message, "error 2");
|
||||
assert_eq!(diags[1].span.line, 3);
|
||||
}
|
||||
|
||||
// ── v0.10 Diagnostic Pipeline Tests ─────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_render_with_code() {
|
||||
let source = "let x = true + 1";
|
||||
let diag = Diagnostic::error("type mismatch", span(1, 9, 8, 16))
|
||||
.with_code("E0003");
|
||||
let output = render(&diag, source);
|
||||
assert!(output.contains("[E0003]"), "should include error code");
|
||||
assert!(output.contains("type mismatch"), "should include message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_with_label() {
|
||||
let source = "let x: Int = \"hello\"";
|
||||
let diag = Diagnostic::error("type mismatch", span(1, 14, 13, 20))
|
||||
.with_label(span(1, 8, 7, 10), "expected type declared here");
|
||||
let output = render(&diag, source);
|
||||
assert!(output.contains("ERROR"), "should be error severity");
|
||||
assert!(output.contains("type mismatch"), "should have message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_hint() {
|
||||
let source = "let x = 0";
|
||||
let diag = Diagnostic::hint("consider using a more descriptive name", span(1, 5, 4, 5));
|
||||
let output = render(&diag, source);
|
||||
assert!(output.contains("HINT") || output.contains("hint"), "should render as hint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_multiline_source() {
|
||||
let source = "let a = 0\nlet b = a + 1\nlet c = b * 2";
|
||||
let diag = Diagnostic::warning("unused signal", span(3, 5, 28, 29));
|
||||
let output = render(&diag, source);
|
||||
assert!(output.contains("3:"), "should reference line 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_col_zero_edge() {
|
||||
let source = "let x = 0";
|
||||
let diag = Diagnostic::error("test", span(1, 0, 0, 3));
|
||||
// Should not panic with col=0
|
||||
let output = render(&diag, source);
|
||||
assert!(output.contains("ERROR"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_minimal_source() {
|
||||
// Minimal single-character source
|
||||
let source = " ";
|
||||
let diag = Diagnostic::error("unexpected end of input", span(1, 1, 0, 1));
|
||||
let output = render(&diag, source);
|
||||
assert!(output.contains("unexpected end of input"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@
|
|||
|
||||
All notable changes to this package will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
## [0.6.0] - 2026-03-10
|
||||
|
||||
## [0.1.0] - 2026-02-26
|
||||
### Added
|
||||
- 7 new tests: error recovery (error→fix→full recompile), whitespace-only no-op, comment-only no-op, signal add/remove detection (structural→full), multi-signal targeted patch, large program (10 signals) incremental
|
||||
|
||||
### 🚀 Features
|
||||
### Test Coverage
|
||||
- **12 tests** (was 5 in v0.5.0)
|
||||
|
||||
- Initial release — Incremental compilation support
|
||||
## [0.5.0] - 2026-03-09
|
||||
|
||||
- Initial release with incremental compilation and diff-based patching
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "ds-incremental"
|
||||
version = "0.1.0"
|
||||
version = "0.6.0"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
|
|
@ -293,4 +293,112 @@ mod tests {
|
|||
let result = compiler.compile(src2);
|
||||
assert!(matches!(result, IncrementalResult::Full(_)));
|
||||
}
|
||||
|
||||
// ── v0.7 Incremental Compiler Tests ─────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_error_then_fix_is_full() {
|
||||
let mut compiler = IncrementalCompiler::new();
|
||||
let src1 = "let x = 42\nview main = column [\n text x\n]";
|
||||
compiler.compile(src1);
|
||||
// Introduce a syntax error
|
||||
let bad = "let x = !@#\nview main = column [\n text x\n]";
|
||||
let err_result = compiler.compile(bad);
|
||||
assert!(matches!(err_result, IncrementalResult::Error(_)));
|
||||
// Fix the error with a DIFFERENT valid source — should trigger full
|
||||
// (prev_program is from src1, but the new source has structural changes)
|
||||
let src3 = "let x = 42\nlet y = 10\nview main = column [\n text x\n text y\n]";
|
||||
let result = compiler.compile(src3);
|
||||
assert!(matches!(result, IncrementalResult::Full(_)),
|
||||
"fixing a syntax error with new structure should trigger full recompile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whitespace_only_change() {
|
||||
let mut compiler = IncrementalCompiler::new();
|
||||
let src1 = "let x = 42\nview main = column [\n text x\n]";
|
||||
let src2 = "let x = 42\n\nview main = column [\n text x\n]";
|
||||
compiler.compile(src1);
|
||||
let result = compiler.compile(src2);
|
||||
// Whitespace change in between declarations — source changes but AST stays same
|
||||
match result {
|
||||
IncrementalResult::Patch(js) => assert!(js.is_empty(), "whitespace-only should be no-op patch"),
|
||||
IncrementalResult::Full(_) => {} // also acceptable — parser may differ on spans
|
||||
other => panic!("unexpected: {:?}", std::mem::discriminant(&other)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comment_only_change() {
|
||||
let mut compiler = IncrementalCompiler::new();
|
||||
let src1 = "let x = 42\nview main = column [\n text x\n]";
|
||||
let src2 = "-- a comment\nlet x = 42\nview main = column [\n text x\n]";
|
||||
compiler.compile(src1);
|
||||
let result = compiler.compile(src2);
|
||||
// Adding a comment shouldn't change signals or views
|
||||
match result {
|
||||
IncrementalResult::Patch(js) => assert!(js.is_empty(), "comment-only should be no-op"),
|
||||
IncrementalResult::Full(_) => {} // also acceptable
|
||||
other => panic!("unexpected: {:?}", std::mem::discriminant(&other)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_signal_is_full() {
|
||||
let mut compiler = IncrementalCompiler::new();
|
||||
let src1 = "let x = 1\nview main = text x";
|
||||
let src2 = "let x = 1\nlet y = 2\nview main = text x";
|
||||
compiler.compile(src1);
|
||||
let result = compiler.compile(src2);
|
||||
assert!(matches!(result, IncrementalResult::Full(_)),
|
||||
"adding a signal should trigger full recompile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_signal_is_full() {
|
||||
let mut compiler = IncrementalCompiler::new();
|
||||
let src1 = "let x = 1\nlet y = 2\nview main = text x";
|
||||
let src2 = "let x = 1\nview main = text x";
|
||||
compiler.compile(src1);
|
||||
let result = compiler.compile(src2);
|
||||
assert!(matches!(result, IncrementalResult::Full(_)),
|
||||
"removing a signal should trigger full recompile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_signal_patch() {
|
||||
let mut compiler = IncrementalCompiler::new();
|
||||
let src1 = "let a = 1\nlet b = 2\nlet c = 3\nview main = column [\n text a\n text b\n text c\n]";
|
||||
let src2 = "let a = 10\nlet b = 20\nlet c = 3\nview main = column [\n text a\n text b\n text c\n]";
|
||||
compiler.compile(src1);
|
||||
let result = compiler.compile(src2);
|
||||
match result {
|
||||
IncrementalResult::Patch(js) => {
|
||||
assert!(js.contains("DS.signals.a"), "should patch signal a");
|
||||
assert!(js.contains("DS.signals.b"), "should patch signal b");
|
||||
assert!(!js.contains("DS.signals.c"), "should NOT patch unchanged signal c");
|
||||
}
|
||||
other => panic!("expected patch for multi-signal change, got {:?}", std::mem::discriminant(&other)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_large_program_incremental() {
|
||||
let mut compiler = IncrementalCompiler::new();
|
||||
let base = "let s0 = 0\nlet s1 = 1\nlet s2 = 2\nlet s3 = 3\nlet s4 = 4\n\
|
||||
let s5 = 5\nlet s6 = 6\nlet s7 = 7\nlet s8 = 8\nlet s9 = 9\n\
|
||||
view main = column [\n text s0\n text s1\n text s9\n]";
|
||||
compiler.compile(base);
|
||||
// Change only one signal in a 10-signal program
|
||||
let modified = base.replace("let s5 = 5", "let s5 = 55");
|
||||
let result = compiler.compile(&modified);
|
||||
match result {
|
||||
IncrementalResult::Patch(js) => {
|
||||
assert!(js.contains("DS.signals.s5"), "should patch only s5");
|
||||
assert!(js.contains("55"), "should contain new value");
|
||||
}
|
||||
other => panic!("expected patch, got {:?}", std::mem::discriminant(&other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@
|
|||
|
||||
All notable changes to this package will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
## [0.6.0] - 2026-03-10
|
||||
|
||||
## [0.1.0] - 2026-02-26
|
||||
### Added
|
||||
- 6 new solver tests: LTE constraint clamping, viewport proportion (ratio 0.25), cascading equality chain (a=b=c), combined GTE+LTE clamp, over-constrained no-panic, zero-width edge case
|
||||
|
||||
### 🚀 Features
|
||||
### Test Coverage
|
||||
- **13 tests** (was 7 in v0.5.0)
|
||||
|
||||
- Initial release — Cassowary constraint solver with eq/gte/lte/sum_eq/ratio constraints
|
||||
## [0.5.0] - 2026-03-09
|
||||
|
||||
- Initial release with Cassowary-inspired constraint solver
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "ds-layout"
|
||||
version = "0.1.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
|
|
@ -453,12 +453,9 @@ mod tests {
|
|||
let main_x = Variable::new();
|
||||
let main_w = Variable::new();
|
||||
|
||||
// sidebar starts at 0, width 200
|
||||
solver.add_constraint(Constraint::eq_const(sidebar_x, 0.0, Strength::Required));
|
||||
solver.add_constraint(Constraint::eq_const(sidebar_w, 200.0, Strength::Required));
|
||||
// main starts where sidebar ends: main_x = sidebar_x + sidebar_w = 200
|
||||
solver.add_constraint(Constraint::eq_const(main_x, 200.0, Strength::Required));
|
||||
// main_x + main_w = 1000 (total width)
|
||||
solver.add_constraint(Constraint::sum_eq(main_x, main_w, 1000.0, Strength::Required));
|
||||
|
||||
solver.solve();
|
||||
|
|
@ -469,4 +466,81 @@ mod tests {
|
|||
assert!((solver.get_value(main_w) - 800.0).abs() < 0.01,
|
||||
"main_w = {}", solver.get_value(main_w));
|
||||
}
|
||||
|
||||
// ── v0.7 Layout Solver Tests ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_lte_constraint() {
|
||||
let mut solver = LayoutSolver::new();
|
||||
let w = Variable::new();
|
||||
// Set width to 600 then constrain <= 500
|
||||
solver.add_constraint(Constraint::eq_const(w, 600.0, Strength::Medium));
|
||||
solver.add_constraint(Constraint::lte_const(w, 500.0, Strength::Required));
|
||||
solver.solve();
|
||||
let val = solver.get_value(w);
|
||||
assert!(val <= 500.01, "w should be <= 500, got {}", val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_viewport_proportion() {
|
||||
let mut solver = LayoutSolver::new();
|
||||
let viewport_w = Variable::new();
|
||||
let sidebar_w = Variable::new();
|
||||
// viewport = 1000, sidebar = 0.25 * viewport
|
||||
solver.add_constraint(Constraint::eq_const(viewport_w, 1000.0, Strength::Required));
|
||||
solver.add_constraint(Constraint::ratio(viewport_w, sidebar_w, 0.25, Strength::Required));
|
||||
solver.solve();
|
||||
assert!((solver.get_value(sidebar_w) - 250.0).abs() < 0.01,
|
||||
"sidebar_w = {}", solver.get_value(sidebar_w));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cascading_eq() {
|
||||
let mut solver = LayoutSolver::new();
|
||||
let a = Variable::new();
|
||||
let b = Variable::new();
|
||||
let c = Variable::new();
|
||||
// a = 100, b = a, c = b → all should be 100
|
||||
solver.add_constraint(Constraint::eq_const(a, 100.0, Strength::Required));
|
||||
solver.add_constraint(Constraint::eq(b, a, Strength::Required));
|
||||
solver.add_constraint(Constraint::eq(c, b, Strength::Required));
|
||||
solver.solve();
|
||||
assert!((solver.get_value(a) - 100.0).abs() < 0.01);
|
||||
assert!((solver.get_value(b) - 100.0).abs() < 0.01);
|
||||
assert!((solver.get_value(c) - 100.0).abs() < 0.01, "c = {}", solver.get_value(c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_combined_gte_lte_clamp() {
|
||||
let mut solver = LayoutSolver::new();
|
||||
let w = Variable::new();
|
||||
// Clamp: 200 <= w <= 400
|
||||
solver.add_constraint(Constraint::gte_const(w, 200.0, Strength::Required));
|
||||
solver.add_constraint(Constraint::lte_const(w, 400.0, Strength::Required));
|
||||
solver.solve();
|
||||
let val = solver.get_value(w);
|
||||
assert!(val >= 199.99 && val <= 400.01,
|
||||
"w should be in [200, 400], got {}", val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_over_constrained_no_panic() {
|
||||
// Conflicting constraints should not panic
|
||||
let mut solver = LayoutSolver::new();
|
||||
let w = Variable::new();
|
||||
solver.add_constraint(Constraint::eq_const(w, 100.0, Strength::Required));
|
||||
solver.add_constraint(Constraint::eq_const(w, 200.0, Strength::Required));
|
||||
solver.solve(); // Should not panic
|
||||
let _val = solver.get_value(w); // Just assert it resolves
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_width() {
|
||||
let mut solver = LayoutSolver::new();
|
||||
let w = Variable::new();
|
||||
solver.add_constraint(Constraint::eq_const(w, 0.0, Strength::Required));
|
||||
solver.solve();
|
||||
assert!((solver.get_value(w) - 0.0).abs() < 0.01, "w = {}", solver.get_value(w));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,22 @@
|
|||
|
||||
All notable changes to this package will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
## [0.6.0] - 2026-03-10
|
||||
|
||||
## [0.1.0] - 2026-02-26
|
||||
### Added
|
||||
- **Rust-like match patterns**: `Tuple(Vec<Pattern>)`, `IntLiteral(i64)`, `BoolLiteral(bool)` pattern variants
|
||||
- Parse tuple patterns: `(a, b) ->`
|
||||
- Parse boolean literal patterns: `true ->` / `false ->`
|
||||
- Parse integer literal patterns: `42 ->`
|
||||
- Parse nested constructors: `Some(Ok(x)) ->`
|
||||
- Wildcard `_` now correctly parsed as `Pattern::Wildcard` (was `Ident("_")`)
|
||||
- `can_be_pattern()` recognizes `LParen`, `True`, `False` as pattern starts
|
||||
- 5 new parser tests for match patterns (tuple, int, bool, nested, mixed arms)
|
||||
- 12 resilient parsing tests covering all Declaration and Expression variants
|
||||
|
||||
### 🚀 Features
|
||||
### Test Coverage
|
||||
- **49 tests** (was 32 in v0.5.0)
|
||||
|
||||
- Initial release — Lexer, recursive descent parser, AST types, operator precedence, string interpolation
|
||||
## [0.5.0] - 2026-03-09
|
||||
|
||||
- Initial release with full DreamStack language parser
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "ds-parser"
|
||||
version = "0.1.0"
|
||||
version = "0.6.0"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
|
|
@ -448,6 +448,12 @@ pub enum Pattern {
|
|||
Ident(String),
|
||||
Constructor(String, Vec<Pattern>),
|
||||
Literal(Expr),
|
||||
/// Tuple pattern: `(a, b, c)`
|
||||
Tuple(Vec<Pattern>),
|
||||
/// Integer literal pattern: `42`
|
||||
IntLiteral(i64),
|
||||
/// Boolean literal pattern: `true` / `false`
|
||||
BoolLiteral(bool),
|
||||
}
|
||||
|
||||
/// Modifiers: `| animate fade-in 200ms`
|
||||
|
|
|
|||
|
|
@ -161,14 +161,13 @@ impl Parser {
|
|||
match self.peek() {
|
||||
// String and integer literals are always valid patterns
|
||||
TokenKind::StringFragment(_) | TokenKind::Int(_) => {
|
||||
// But verify: is this FOLLOWED by an arrow (after the string)?
|
||||
// StringFragment patterns look like: "value" ->
|
||||
// View elements look like: text "value" (no arrow after the string)
|
||||
true
|
||||
}
|
||||
// Identifiers: could be a pattern binding OR a view element (text, button, input...)
|
||||
// Look ahead: patterns are followed eventually by Arrow
|
||||
// e.g., `_ -> body` or `myVar -> body` or `Ok(x) -> body`
|
||||
// Boolean literals: `true ->` / `false ->`
|
||||
TokenKind::True | TokenKind::False => true,
|
||||
// Tuple pattern: `(a, b) ->`
|
||||
TokenKind::LParen => true,
|
||||
// Identifiers: could be a pattern binding OR a view element
|
||||
TokenKind::Ident(name) => {
|
||||
// Special case: _ is always a wildcard pattern
|
||||
if name == "_" {
|
||||
|
|
@ -178,12 +177,8 @@ impl Parser {
|
|||
let next_pos = self.pos + 1;
|
||||
if next_pos < self.tokens.len() {
|
||||
match &self.tokens[next_pos].kind {
|
||||
// Ident followed by Arrow: definitely a pattern (`myVar ->`)
|
||||
TokenKind::Arrow => true,
|
||||
// Ident followed by `(`: constructor pattern (`Ok(x) ->`)
|
||||
TokenKind::LParen => true,
|
||||
// Ident followed by Newline: could be pattern on next line
|
||||
// Check the token after the newline(s)
|
||||
TokenKind::Newline => {
|
||||
let mut peek_pos = next_pos + 1;
|
||||
while peek_pos < self.tokens.len() && self.tokens[peek_pos].kind == TokenKind::Newline {
|
||||
|
|
@ -195,8 +190,6 @@ impl Parser {
|
|||
false
|
||||
}
|
||||
}
|
||||
// Ident followed by anything else (string, LBrace, LBracket, etc.):
|
||||
// it's a view element like `text "hello"` or `button "click" { }`
|
||||
_ => false,
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1872,8 +1865,10 @@ impl Parser {
|
|||
match self.peek().clone() {
|
||||
TokenKind::Ident(name) => {
|
||||
self.advance();
|
||||
if self.check(&TokenKind::LParen) {
|
||||
// Constructor pattern: `Ok(value)`
|
||||
if name == "_" {
|
||||
Ok(Pattern::Wildcard)
|
||||
} else if self.check(&TokenKind::LParen) {
|
||||
// Constructor pattern: `Ok(value)` or `Some(Ok(x))`
|
||||
self.advance();
|
||||
let mut fields = Vec::new();
|
||||
while !self.check(&TokenKind::RParen) && !self.is_at_end() {
|
||||
|
|
@ -1890,7 +1885,28 @@ impl Parser {
|
|||
}
|
||||
TokenKind::Int(n) => {
|
||||
self.advance();
|
||||
Ok(Pattern::Literal(Expr::IntLit(n)))
|
||||
Ok(Pattern::IntLiteral(n))
|
||||
}
|
||||
TokenKind::True => {
|
||||
self.advance();
|
||||
Ok(Pattern::BoolLiteral(true))
|
||||
}
|
||||
TokenKind::False => {
|
||||
self.advance();
|
||||
Ok(Pattern::BoolLiteral(false))
|
||||
}
|
||||
TokenKind::LParen => {
|
||||
// Tuple pattern: `(a, b, c)`
|
||||
self.advance();
|
||||
let mut elements = Vec::new();
|
||||
while !self.check(&TokenKind::RParen) && !self.is_at_end() {
|
||||
elements.push(self.parse_pattern()?);
|
||||
if self.check(&TokenKind::Comma) {
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
self.expect(&TokenKind::RParen)?;
|
||||
Ok(Pattern::Tuple(elements))
|
||||
}
|
||||
TokenKind::StringFragment(s) => {
|
||||
self.advance();
|
||||
|
|
@ -2246,4 +2262,196 @@ let b = stream from "ws://localhost:9101""#);
|
|||
let prog = parse("let a = 1\nlet b = 2\nlet c = 3");
|
||||
assert_eq!(prog.declarations.len(), 3);
|
||||
}
|
||||
|
||||
// ── v0.6 Parser Hardening Tests ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_import_decl() {
|
||||
let prog = parse("import { Button, Card } from \"./components\"");
|
||||
match &prog.declarations[0] {
|
||||
Declaration::Import(imp) => {
|
||||
assert_eq!(imp.source, "./components");
|
||||
assert_eq!(imp.names, vec!["Button", "Card"]);
|
||||
}
|
||||
other => panic!("expected Import, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_decl() {
|
||||
let prog = parse("export let theme = \"dark\"");
|
||||
match &prog.declarations[0] {
|
||||
Declaration::Export(name, inner) => {
|
||||
assert_eq!(name, "theme");
|
||||
match inner.as_ref() {
|
||||
Declaration::Let(d) => assert_eq!(d.name, "theme"),
|
||||
other => panic!("expected inner Let, got {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("expected Export, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_component_decl() {
|
||||
let prog = parse("component Card(title, subtitle) =\n column [\n text title\n text subtitle\n ]");
|
||||
match &prog.declarations[0] {
|
||||
Declaration::Component(c) => {
|
||||
assert_eq!(c.name, "Card");
|
||||
assert_eq!(c.props.len(), 2);
|
||||
}
|
||||
other => panic!("expected Component, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_decl() {
|
||||
let prog = parse("route \"/settings\" -> settings_view");
|
||||
match &prog.declarations[0] {
|
||||
Declaration::Route(r) => {
|
||||
assert_eq!(r.path, "/settings");
|
||||
}
|
||||
other => panic!("expected Route, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_every_decl() {
|
||||
let prog = parse("let t = 0\nevery 16 -> t = t + 1");
|
||||
let has_every = prog.declarations.iter().any(|d| matches!(d, Declaration::Every(_)));
|
||||
assert!(has_every, "expected Every declaration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_alias() {
|
||||
let prog = parse("type Color = String");
|
||||
match &prog.declarations[0] {
|
||||
Declaration::TypeAlias(ta) => {
|
||||
assert_eq!(ta.name, "Color");
|
||||
}
|
||||
other => panic!("expected TypeAlias, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layout_decl() {
|
||||
let prog = parse("layout dashboard {\n sidebar.width == 250\n}");
|
||||
match &prog.declarations[0] {
|
||||
Declaration::Layout(l) => {
|
||||
assert_eq!(l.name, "dashboard");
|
||||
assert!(!l.constraints.is_empty());
|
||||
}
|
||||
other => panic!("expected Layout, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effect_decl() {
|
||||
let prog = parse("effect fetchUser(id): Result");
|
||||
match &prog.declarations[0] {
|
||||
Declaration::Effect(e) => {
|
||||
assert_eq!(e.name, "fetchUser");
|
||||
assert!(!e.params.is_empty());
|
||||
}
|
||||
other => panic!("expected Effect, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_expr() {
|
||||
let prog = parse("let mood = \"happy\"\nview main = column [\n match mood\n \"happy\" -> text \"great\"\n _ -> text \"ok\"\n]");
|
||||
match &prog.declarations[1] {
|
||||
Declaration::View(v) => {
|
||||
match &v.body {
|
||||
Expr::Container(c) => {
|
||||
assert!(matches!(&c.children[0], Expr::Match(_, _)));
|
||||
}
|
||||
other => panic!("expected Container, got {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("expected View, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_for_in_expr() {
|
||||
let prog = parse("view main = column [\n for item in items ->\n text item\n]");
|
||||
match &prog.declarations[0] {
|
||||
Declaration::View(v) => {
|
||||
match &v.body {
|
||||
Expr::Container(c) => {
|
||||
assert!(matches!(&c.children[0], Expr::ForIn { .. }));
|
||||
}
|
||||
other => panic!("expected Container, got {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("expected View, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_string_interpolation() {
|
||||
let prog = parse("let greeting = \"hello {name}\"");
|
||||
match &prog.declarations[0] {
|
||||
Declaration::Let(d) => {
|
||||
match &d.value {
|
||||
Expr::StringLit(s) => {
|
||||
assert!(!s.segments.is_empty(), "should have interpolation parts");
|
||||
}
|
||||
other => panic!("expected StringLit, got {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("expected Let, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_constrain_decl() {
|
||||
let prog = parse("constrain sidebar.width = 250");
|
||||
match &prog.declarations[0] {
|
||||
Declaration::Constrain(_) => {}
|
||||
other => panic!("expected Constrain, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── v0.9 Rust-Like Match Pattern Tests ──────────────────
|
||||
|
||||
#[test]
|
||||
fn test_match_tuple_pattern() {
|
||||
let prog = parse("let pair = (1, 2)\nview main = match pair\n (a, b) -> text \"pair\"");
|
||||
// Should parse without error — view-level match with tuple pattern
|
||||
assert!(!prog.declarations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_int_literal_pattern() {
|
||||
let prog = parse("let n = 42\nview main = match n\n 0 -> text \"zero\"\n 1 -> text \"one\"\n _ -> text \"other\"");
|
||||
assert!(prog.declarations.len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_bool_literal_pattern() {
|
||||
let prog = parse("let flag = true\nview main = match flag\n true -> text \"yes\"\n false -> text \"no\"");
|
||||
assert!(prog.declarations.len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_nested_constructor() {
|
||||
let prog = parse("view main = match result\n Some(Ok(x)) -> text \"good\"\n Some(Error(e)) -> text \"bad\"\n None -> text \"nothing\"");
|
||||
assert!(!prog.declarations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_mixed_arms() {
|
||||
let prog = parse("let status = \"ok\"\nlet label = match status\n \"loading\" -> \"wait\"\n \"ok\" -> \"done\"\n _ -> \"unknown\"");
|
||||
// Expression-level match with string literals and wildcard
|
||||
match &prog.declarations[1] {
|
||||
Declaration::Let(d) => {
|
||||
assert!(matches!(&d.value, Expr::Match(_, arms) if arms.len() == 3));
|
||||
}
|
||||
other => panic!("expected Let with Match, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,16 @@
|
|||
|
||||
All notable changes to this package will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
## [0.6.0] - 2026-03-10
|
||||
|
||||
## [0.1.0] - 2026-02-26
|
||||
### Added
|
||||
- Exhaustiveness checking handles all 7 Pattern variants (Tuple, IntLiteral, BoolLiteral added)
|
||||
- 5 match type checker tests: exhaustive enum, non-exhaustive E0111, wildcard exhaustive, match return type, expression-level match
|
||||
- 6 integration tests for signal type inference (Int, String, Bool, Float, multi-signal)
|
||||
|
||||
### 🚀 Features
|
||||
### Test Coverage
|
||||
- **50 tests** (was 39 in v0.5.0)
|
||||
|
||||
- Initial release — Hindley-Milner type checker, Signal/Derived/Stream/Spring/View types, Elm-style errors
|
||||
## [0.5.0] - 2026-03-09
|
||||
|
||||
- Initial release with Hindley-Milner type inference and enum exhaustiveness checking
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "ds-types"
|
||||
version = "0.1.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
|
|
@ -932,6 +932,9 @@ impl TypeChecker {
|
|||
Pattern::Ident(p) => { matched.insert(p.clone()); }
|
||||
Pattern::Literal(_) => {}
|
||||
Pattern::Constructor(p, _) => { matched.insert(p.clone()); }
|
||||
Pattern::IntLiteral(_) => {}
|
||||
Pattern::BoolLiteral(_) => {}
|
||||
Pattern::Tuple(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1709,4 +1712,141 @@ mod tests {
|
|||
assert_eq!(ty, Type::Int);
|
||||
assert!(!checker.has_errors(), "Errors: {}", checker.display_errors());
|
||||
}
|
||||
|
||||
// ── v0.6 Integration Tests ──────────────────────────────
|
||||
|
||||
/// Parse DreamStack source and type-check it.
|
||||
fn check_source(src: &str) -> TypeChecker {
|
||||
let mut lexer = ds_parser::Lexer::new(src);
|
||||
let tokens = lexer.tokenize();
|
||||
let mut parser = ds_parser::Parser::new(tokens);
|
||||
let program = parser.parse_program().expect("parse failed");
|
||||
let mut checker = TypeChecker::new();
|
||||
checker.check_program(&program);
|
||||
checker
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_counter_program_no_errors() {
|
||||
let checker = check_source(
|
||||
"let count = 0\nlet doubled = count * 2\nview main = column [ text \"hi\" ]"
|
||||
);
|
||||
assert!(!checker.has_errors(), "counter should have no type errors: {}", checker.display_errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_errors_as_diagnostics_conversion() {
|
||||
let mut checker = TypeChecker::new();
|
||||
let program = make_program(vec![
|
||||
Declaration::Let(LetDecl {
|
||||
name: "x".to_string(),
|
||||
type_annotation: None,
|
||||
value: Expr::BinOp(
|
||||
Box::new(Expr::IntLit(1)),
|
||||
BinOp::Add,
|
||||
Box::new(Expr::StringLit(ds_parser::StringLit {
|
||||
segments: vec![ds_parser::StringSegment::Literal("hello".to_string())],
|
||||
})),
|
||||
),
|
||||
visibility: ds_parser::Visibility::Private,
|
||||
doc: None,
|
||||
span: span(),
|
||||
}),
|
||||
]);
|
||||
checker.check_program(&program);
|
||||
let diags = checker.errors_as_diagnostics();
|
||||
// Whether or not there's a type error, the diagnostics should be well-formed
|
||||
for diag in &diags {
|
||||
assert!(!diag.message.is_empty());
|
||||
assert!(diag.code.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derived_int_arithmetic() {
|
||||
let checker = check_source(
|
||||
"let a = 10\nlet b = 20\nlet sum = a + b"
|
||||
);
|
||||
assert!(!checker.has_errors(), "int arithmetic should not error: {}", checker.display_errors());
|
||||
assert_eq!(*checker.type_env().get("a").unwrap(), Type::Signal(Box::new(Type::Int)));
|
||||
assert_eq!(*checker.type_env().get("b").unwrap(), Type::Signal(Box::new(Type::Int)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_string_signal_type() {
|
||||
let checker = check_source("let name = \"world\"");
|
||||
assert!(!checker.has_errors(), "string let should not error: {}", checker.display_errors());
|
||||
assert_eq!(*checker.type_env().get("name").unwrap(), Type::Signal(Box::new(Type::String)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bool_signal_type() {
|
||||
let checker = check_source("let active = true");
|
||||
assert!(!checker.has_errors(), "bool let should not error: {}", checker.display_errors());
|
||||
assert_eq!(*checker.type_env().get("active").unwrap(), Type::Signal(Box::new(Type::Bool)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_signals_type_env() {
|
||||
let checker = check_source(
|
||||
"let count = 0\nlet name = \"test\"\nlet active = false\nlet ratio = 3.14"
|
||||
);
|
||||
assert!(!checker.has_errors(), "multi-signal: {}", checker.display_errors());
|
||||
assert_eq!(*checker.type_env().get("count").unwrap(), Type::Signal(Box::new(Type::Int)));
|
||||
assert_eq!(*checker.type_env().get("name").unwrap(), Type::Signal(Box::new(Type::String)));
|
||||
assert_eq!(*checker.type_env().get("active").unwrap(), Type::Signal(Box::new(Type::Bool)));
|
||||
assert_eq!(*checker.type_env().get("ratio").unwrap(), Type::Signal(Box::new(Type::Float)));
|
||||
}
|
||||
|
||||
// ── v0.9 Rust-Like Match Type Checker Tests ─────────────
|
||||
|
||||
#[test]
|
||||
fn test_exhaustive_enum_match_no_error() {
|
||||
let checker = check_source(
|
||||
"enum Color { Red, Green, Blue }\nlet c = 0\nlet label = match c\n Red -> \"r\"\n Green -> \"g\"\n Blue -> \"b\""
|
||||
);
|
||||
// Exhaustive match on Color — should not produce NonExhaustiveMatch
|
||||
let has_exhaustive_err = checker.display_errors().contains("non-exhaustive");
|
||||
assert!(!has_exhaustive_err, "exhaustive match should not error: {}", checker.display_errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_exhaustive_enum_match_error() {
|
||||
let checker = check_source(
|
||||
"enum Color { Red, Green, Blue }\nlet c = 0\nlet label = match c\n Red -> \"r\"\n Green -> \"g\""
|
||||
);
|
||||
// Missing Blue — should produce non-exhaustive error
|
||||
let errors = checker.display_errors();
|
||||
assert!(errors.contains("non-exhaustive") || errors.contains("Blue") || checker.has_errors(),
|
||||
"non-exhaustive match should warn about Blue: {}", errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_always_exhaustive() {
|
||||
let checker = check_source(
|
||||
"enum Dir { Up, Down, Left, Right }\nlet d = 0\nlet label = match d\n Up -> \"up\"\n _ -> \"other\""
|
||||
);
|
||||
let has_exhaustive_err = checker.display_errors().contains("non-exhaustive");
|
||||
assert!(!has_exhaustive_err, "wildcard should make match exhaustive: {}", checker.display_errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_return_type() {
|
||||
// All arms return String — match type should be String
|
||||
let checker = check_source(
|
||||
"let status = \"ok\"\nlet msg = match status\n \"ok\" -> \"good\"\n _ -> \"bad\""
|
||||
);
|
||||
assert!(!checker.has_errors(), "str match: {}", checker.display_errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_expr_level_type() {
|
||||
// Expression-level match with int patterns
|
||||
let checker = check_source(
|
||||
"let n = 42\nlet label = match n\n 0 -> \"zero\"\n 1 -> \"one\"\n _ -> \"many\""
|
||||
);
|
||||
assert!(!checker.has_errors(), "int-pattern match: {}", checker.display_errors());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,32 @@
|
|||
# Changelog
|
||||
|
||||
## [0.9.0] - 2026-03-10
|
||||
|
||||
### Added
|
||||
- **Sensor bodies**: `create_sensor_circle`, `create_sensor_rect`, `get_sensor_overlaps`, `is_sensor`
|
||||
- **Revolute joints**: `create_revolute_joint`, `set_joint_motor`, `set_joint_motor_position`
|
||||
- **Prismatic joints**: `create_prismatic_joint`, `set_prismatic_limits`, `set_prismatic_motor`
|
||||
- **Collision events**: `enable_contact_events`, `clear_collision_events`
|
||||
- **Body sleeping**: `sleep_body`, `wake_body`, `is_sleeping`
|
||||
- **Rope joints**: `create_rope_joint` (max distance constraint)
|
||||
|
||||
### Fixed
|
||||
- `apply_force()` → `add_force()` (Rapier2D API)
|
||||
- Pattern binding in `get_bodies_by_tag()`
|
||||
- All dead code warnings suppressed
|
||||
|
||||
### Test Coverage
|
||||
- **49 tests** (was 35)
|
||||
|
||||
### Joint Family
|
||||
| Type | ID | Functions |
|
||||
|------|---:|-----------|
|
||||
| Spring | 0 | `create_spring_joint` |
|
||||
| Fixed | 1 | `create_fixed_joint` |
|
||||
| Revolute | 2 | `create_revolute_joint`, `set_joint_motor`, `set_joint_motor_position` |
|
||||
| Prismatic | 3 | `create_prismatic_joint`, `set_prismatic_limits`, `set_prismatic_motor` |
|
||||
| Rope | 4 | `create_rope_joint` |
|
||||
|
||||
## [0.5.0] - 2026-03-09
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- **Particle emitters** — `create_emitter(x, y, rate, speed, spread, lifetime)`, `remove_emitter`, `set_emitter_position`, auto-spawn in `step()`
|
||||
- **Force fields** — `create_force_field(x, y, radius, strength)`, `remove_force_field`, attract/repel applied each step
|
||||
- **Body tags** — `set_body_tag`, `get_body_tag`, `get_bodies_by_tag` for user-defined categorization
|
||||
- **Contact materials** — `set_contact_material(tag_a, tag_b, friction, restitution)`, `get_contact_material`
|
||||
|
||||
## [0.4.0] - 2026-03-09 — Collision groups/events, point/AABB query, body locking, gravity scale
|
||||
## [0.3.0] - 2026-03-09 — Joints (spring, fixed), serialize/deserialize, velocity/force, boundaries
|
||||
## [0.2.0] - 2026-03-09 — Body management, sensors, material properties, sleeping
|
||||
## [0.1.0] - 2026-02-26 — Initial release
|
||||
- Initial release
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "ds-physics"
|
||||
version = "0.5.0"
|
||||
version = "0.9.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ struct BodyInfo {
|
|||
handle: RigidBodyHandle,
|
||||
collider_handle: Option<ColliderHandle>,
|
||||
// Soft body particle handles (for soft circles)
|
||||
#[allow(dead_code)]
|
||||
particle_handles: Vec<RigidBodyHandle>,
|
||||
segments: usize,
|
||||
removed: bool,
|
||||
|
|
@ -62,6 +63,7 @@ struct EmitterInfo {
|
|||
rate: f32, // particles per second
|
||||
speed: f32, // initial velocity magnitude
|
||||
spread: f32, // angle spread in radians
|
||||
#[allow(dead_code)]
|
||||
lifetime: f32, // seconds before auto-removal
|
||||
accumulator: f32,
|
||||
particle_indices: Vec<usize>, // body indices of spawned particles
|
||||
|
|
@ -413,6 +415,33 @@ impl PhysicsWorld {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── v0.13: Body Sleeping API ───
|
||||
|
||||
/// Put a body to sleep (deactivates simulation until disturbed).
|
||||
pub fn sleep_body(&mut self, body_idx: usize) {
|
||||
if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return; }
|
||||
let handle = self.bodies[body_idx].handle;
|
||||
if let Some(rb) = self.rigid_body_set.get_mut(handle) {
|
||||
rb.sleep();
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake a sleeping body.
|
||||
pub fn wake_body(&mut self, body_idx: usize) {
|
||||
if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return; }
|
||||
let handle = self.bodies[body_idx].handle;
|
||||
if let Some(rb) = self.rigid_body_set.get_mut(handle) {
|
||||
rb.wake_up(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a body is sleeping.
|
||||
pub fn is_sleeping(&self, body_idx: usize) -> bool {
|
||||
if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return false; }
|
||||
let handle = self.bodies[body_idx].handle;
|
||||
self.rigid_body_set.get(handle).map(|rb| rb.is_sleeping()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Set collider material properties (density, restitution, friction)
|
||||
pub fn set_body_properties(&mut self, body_idx: usize, density: f64, restitution: f64, friction: f64) {
|
||||
if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return; }
|
||||
|
|
@ -461,7 +490,7 @@ impl PhysicsWorld {
|
|||
}
|
||||
|
||||
/// Morph a body (for circles: change radius, for rects: change dimensions)
|
||||
pub fn morph_body(&mut self, body_idx: usize, target_positions: &[f64]) {
|
||||
pub fn morph_body(&mut self, body_idx: usize, _target_positions: &[f64]) {
|
||||
// Morphing is a concept from the spring-mass system
|
||||
// With Rapier, we'd need to recreate colliders — simplified for now
|
||||
if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return; }
|
||||
|
|
@ -822,11 +851,171 @@ impl PhysicsWorld {
|
|||
self.joints[joint_idx].removed = true;
|
||||
}
|
||||
|
||||
// ─── v0.10: Revolute Motor Joints ───
|
||||
|
||||
/// Create a revolute (hinge) joint at a world-space anchor point.
|
||||
/// Bodies can rotate freely around the anchor. joint_type = 2.
|
||||
pub fn create_revolute_joint(
|
||||
&mut self, body_a: usize, body_b: usize,
|
||||
anchor_x: f64, anchor_y: f64,
|
||||
) -> usize {
|
||||
if body_a >= self.bodies.len() || self.bodies[body_a].removed { return usize::MAX; }
|
||||
if body_b >= self.bodies.len() || self.bodies[body_b].removed { return usize::MAX; }
|
||||
|
||||
let handle_a = self.bodies[body_a].handle;
|
||||
let handle_b = self.bodies[body_b].handle;
|
||||
|
||||
// Compute local anchors relative to each body
|
||||
let pos_a = self.rigid_body_set.get(handle_a).map(|rb| *rb.translation()).unwrap_or_default();
|
||||
let pos_b = self.rigid_body_set.get(handle_b).map(|rb| *rb.translation()).unwrap_or_default();
|
||||
|
||||
let anchor = nalgebra::Point2::new(anchor_x as f32, anchor_y as f32);
|
||||
let local_a = nalgebra::Point2::new(anchor.x - pos_a.x, anchor.y - pos_a.y);
|
||||
let local_b = nalgebra::Point2::new(anchor.x - pos_b.x, anchor.y - pos_b.y);
|
||||
|
||||
let joint = RevoluteJointBuilder::new()
|
||||
.local_anchor1(local_a)
|
||||
.local_anchor2(local_b)
|
||||
.build();
|
||||
|
||||
let joint_handle = self.impulse_joint_set.insert(handle_a, handle_b, joint, true);
|
||||
|
||||
let info = JointInfo {
|
||||
joint_type: 2, // revolute
|
||||
body_a,
|
||||
body_b,
|
||||
handle: joint_handle,
|
||||
removed: false,
|
||||
};
|
||||
self.joints.push(info);
|
||||
self.joints.len() - 1
|
||||
}
|
||||
|
||||
/// Set a velocity motor on a revolute joint.
|
||||
/// `target_vel` in radians/sec, `max_torque` is the maximum force.
|
||||
pub fn set_joint_motor(&mut self, joint_idx: usize, target_vel: f64, max_torque: f64) {
|
||||
if joint_idx >= self.joints.len() || self.joints[joint_idx].removed { return; }
|
||||
if self.joints[joint_idx].joint_type != 2 { return; }
|
||||
|
||||
let handle = self.joints[joint_idx].handle;
|
||||
if let Some(joint) = self.impulse_joint_set.get_mut(handle) {
|
||||
let revolute = joint.data.as_revolute_mut().unwrap();
|
||||
revolute.set_motor_velocity(target_vel as f32, max_torque as f32);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a position motor on a revolute joint.
|
||||
/// Drives toward `target_angle` (radians) with spring-like stiffness/damping.
|
||||
pub fn set_joint_motor_position(
|
||||
&mut self, joint_idx: usize,
|
||||
target_angle: f64, stiffness: f64, damping: f64,
|
||||
) {
|
||||
if joint_idx >= self.joints.len() || self.joints[joint_idx].removed { return; }
|
||||
if self.joints[joint_idx].joint_type != 2 { return; }
|
||||
|
||||
let handle = self.joints[joint_idx].handle;
|
||||
if let Some(joint) = self.impulse_joint_set.get_mut(handle) {
|
||||
let revolute = joint.data.as_revolute_mut().unwrap();
|
||||
revolute.set_motor_position(target_angle as f32, stiffness as f32, damping as f32);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── v0.11: Prismatic (Slider) Joints ───
|
||||
|
||||
/// Create a prismatic (slider) joint constraining motion along an axis.
|
||||
/// `axis_x`, `axis_y` define the slide direction. joint_type = 3.
|
||||
pub fn create_prismatic_joint(
|
||||
&mut self, body_a: usize, body_b: usize,
|
||||
axis_x: f64, axis_y: f64,
|
||||
) -> usize {
|
||||
if body_a >= self.bodies.len() || self.bodies[body_a].removed { return usize::MAX; }
|
||||
if body_b >= self.bodies.len() || self.bodies[body_b].removed { return usize::MAX; }
|
||||
|
||||
let handle_a = self.bodies[body_a].handle;
|
||||
let handle_b = self.bodies[body_b].handle;
|
||||
|
||||
let axis = nalgebra::UnitVector2::new_normalize(Vector2::new(axis_x as f32, axis_y as f32));
|
||||
let joint = PrismaticJointBuilder::new(axis)
|
||||
.local_anchor1(nalgebra::Point2::origin())
|
||||
.local_anchor2(nalgebra::Point2::origin())
|
||||
.build();
|
||||
|
||||
let joint_handle = self.impulse_joint_set.insert(handle_a, handle_b, joint, true);
|
||||
|
||||
let info = JointInfo {
|
||||
joint_type: 3, // prismatic
|
||||
body_a,
|
||||
body_b,
|
||||
handle: joint_handle,
|
||||
removed: false,
|
||||
};
|
||||
self.joints.push(info);
|
||||
self.joints.len() - 1
|
||||
}
|
||||
|
||||
/// Set translation limits on a prismatic joint.
|
||||
/// Bodies can only slide between `min` and `max` along the joint axis.
|
||||
pub fn set_prismatic_limits(&mut self, joint_idx: usize, min: f64, max: f64) {
|
||||
if joint_idx >= self.joints.len() || self.joints[joint_idx].removed { return; }
|
||||
if self.joints[joint_idx].joint_type != 3 { return; }
|
||||
|
||||
let handle = self.joints[joint_idx].handle;
|
||||
if let Some(joint) = self.impulse_joint_set.get_mut(handle) {
|
||||
let prismatic = joint.data.as_prismatic_mut().unwrap();
|
||||
prismatic.set_limits([min as f32, max as f32]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a velocity motor on a prismatic joint.
|
||||
/// Drives linear sliding at `target_vel` (units/sec) with `max_force`.
|
||||
pub fn set_prismatic_motor(&mut self, joint_idx: usize, target_vel: f64, max_force: f64) {
|
||||
if joint_idx >= self.joints.len() || self.joints[joint_idx].removed { return; }
|
||||
if self.joints[joint_idx].joint_type != 3 { return; }
|
||||
|
||||
let handle = self.joints[joint_idx].handle;
|
||||
if let Some(joint) = self.impulse_joint_set.get_mut(handle) {
|
||||
let prismatic = joint.data.as_prismatic_mut().unwrap();
|
||||
prismatic.set_motor_velocity(target_vel as f32, max_force as f32);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get joint count (including removed).
|
||||
pub fn joint_count(&self) -> usize {
|
||||
self.joints.len()
|
||||
}
|
||||
|
||||
// ─── v0.13: Rope Joints ───
|
||||
|
||||
/// Create a rope joint (max distance constraint). joint_type = 4.
|
||||
/// Bodies cannot move farther apart than `max_distance`.
|
||||
pub fn create_rope_joint(
|
||||
&mut self, body_a: usize, body_b: usize,
|
||||
max_distance: f64,
|
||||
) -> usize {
|
||||
if body_a >= self.bodies.len() || self.bodies[body_a].removed { return usize::MAX; }
|
||||
if body_b >= self.bodies.len() || self.bodies[body_b].removed { return usize::MAX; }
|
||||
|
||||
let handle_a = self.bodies[body_a].handle;
|
||||
let handle_b = self.bodies[body_b].handle;
|
||||
|
||||
let joint = RopeJointBuilder::new(max_distance as f32)
|
||||
.local_anchor1(nalgebra::Point2::origin())
|
||||
.local_anchor2(nalgebra::Point2::origin())
|
||||
.build();
|
||||
|
||||
let joint_handle = self.impulse_joint_set.insert(handle_a, handle_b, joint, true);
|
||||
|
||||
let info = JointInfo {
|
||||
joint_type: 4, // rope
|
||||
body_a,
|
||||
body_b,
|
||||
handle: joint_handle,
|
||||
removed: false,
|
||||
};
|
||||
self.joints.push(info);
|
||||
self.joints.len() - 1
|
||||
}
|
||||
|
||||
/// Get active joint count.
|
||||
pub fn active_joint_count(&self) -> usize {
|
||||
self.joints.iter().filter(|j| !j.removed).count()
|
||||
|
|
@ -989,6 +1178,24 @@ impl PhysicsWorld {
|
|||
self.collision_events.len()
|
||||
}
|
||||
|
||||
/// Enable collision event reporting for a specific body.
|
||||
/// Sets `ActiveEvents::COLLISION_EVENTS` on its collider.
|
||||
pub fn enable_contact_events(&mut self, body_idx: usize) {
|
||||
if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return; }
|
||||
if let Some(ch) = self.bodies[body_idx].collider_handle {
|
||||
if let Some(collider) = self.collider_set.get_mut(ch) {
|
||||
collider.set_active_events(
|
||||
collider.active_events() | rapier2d::prelude::ActiveEvents::COLLISION_EVENTS
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manually clear the collision event buffer.
|
||||
pub fn clear_collision_events(&mut self) {
|
||||
self.collision_events.clear();
|
||||
}
|
||||
|
||||
// ─── v0.4: Spatial Queries ───
|
||||
|
||||
/// Query which body contains point (x, y). Returns body index or usize::MAX.
|
||||
|
|
@ -1159,7 +1366,7 @@ impl PhysicsWorld {
|
|||
let dist = dist2.sqrt();
|
||||
let falloff = 1.0 - (dist / ff.radius);
|
||||
let force = diff.normalize() * ff.strength * falloff;
|
||||
rb.apply_force(force, true);
|
||||
rb.add_force(force, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1182,7 +1389,7 @@ impl PhysicsWorld {
|
|||
/// Get all body indices with a given tag.
|
||||
pub fn get_bodies_by_tag(&self, tag: u32) -> Vec<usize> {
|
||||
self.body_tags.iter().enumerate()
|
||||
.filter(|(i, &t)| t == tag && *i < self.bodies.len() && !self.bodies[*i].removed)
|
||||
.filter(|(i, t)| **t == tag && *i < self.bodies.len() && !self.bodies[*i].removed)
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -1205,6 +1412,121 @@ impl PhysicsWorld {
|
|||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
// ─── v0.9: Sensor / Trigger Bodies ───
|
||||
|
||||
/// Create a sensor circle (trigger volume — detects overlap, no collision response).
|
||||
/// Returns body index. body_type = 3.
|
||||
pub fn create_sensor_circle(&mut self, cx: f64, cy: f64, radius: f64) -> usize {
|
||||
let rb = RigidBodyBuilder::kinematic_position_based()
|
||||
.translation(Vector2::new(cx as f32, cy as f32))
|
||||
.build();
|
||||
let handle = self.rigid_body_set.insert(rb);
|
||||
|
||||
let collider = ColliderBuilder::ball(radius as f32)
|
||||
.sensor(true)
|
||||
.active_events(rapier2d::prelude::ActiveEvents::COLLISION_EVENTS)
|
||||
.build();
|
||||
let collider_handle = self.collider_set.insert_with_parent(collider, handle, &mut self.rigid_body_set);
|
||||
|
||||
let info = BodyInfo {
|
||||
body_type: 3, // sensor
|
||||
color: [0.2, 0.8, 0.4, 0.3],
|
||||
radius,
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
handle,
|
||||
collider_handle: Some(collider_handle),
|
||||
particle_handles: Vec::new(),
|
||||
segments: 12,
|
||||
removed: false,
|
||||
};
|
||||
self.bodies.push(info);
|
||||
self.bodies.len() - 1
|
||||
}
|
||||
|
||||
/// Create a sensor rectangle (trigger volume).
|
||||
/// Returns body index. body_type = 3.
|
||||
pub fn create_sensor_rect(&mut self, cx: f64, cy: f64, width: f64, height: f64) -> usize {
|
||||
let rb = RigidBodyBuilder::kinematic_position_based()
|
||||
.translation(Vector2::new(cx as f32, cy as f32))
|
||||
.build();
|
||||
let handle = self.rigid_body_set.insert(rb);
|
||||
|
||||
let collider = ColliderBuilder::cuboid((width / 2.0) as f32, (height / 2.0) as f32)
|
||||
.sensor(true)
|
||||
.active_events(rapier2d::prelude::ActiveEvents::COLLISION_EVENTS)
|
||||
.build();
|
||||
let collider_handle = self.collider_set.insert_with_parent(collider, handle, &mut self.rigid_body_set);
|
||||
|
||||
let info = BodyInfo {
|
||||
body_type: 3,
|
||||
color: [0.2, 0.8, 0.4, 0.3],
|
||||
radius: 0.0,
|
||||
width,
|
||||
height,
|
||||
handle,
|
||||
collider_handle: Some(collider_handle),
|
||||
particle_handles: Vec::new(),
|
||||
segments: 0,
|
||||
removed: false,
|
||||
};
|
||||
self.bodies.push(info);
|
||||
self.bodies.len() - 1
|
||||
}
|
||||
|
||||
/// Check if a body is a sensor (trigger volume).
|
||||
pub fn is_sensor(&self, body_idx: usize) -> bool {
|
||||
if body_idx >= self.bodies.len() { return false; }
|
||||
self.bodies[body_idx].body_type == 3
|
||||
}
|
||||
|
||||
/// Get all dynamic bodies currently overlapping a sensor.
|
||||
/// Returns body indices of the overlapping bodies.
|
||||
pub fn get_sensor_overlaps(&self, sensor_idx: usize) -> Vec<usize> {
|
||||
if sensor_idx >= self.bodies.len() || self.bodies[sensor_idx].removed { return Vec::new(); }
|
||||
if self.bodies[sensor_idx].body_type != 3 { return Vec::new(); }
|
||||
|
||||
let Some(sensor_ch) = self.bodies[sensor_idx].collider_handle else { return Vec::new(); };
|
||||
let mut overlaps = Vec::new();
|
||||
|
||||
// Check all contact pairs involving this sensor
|
||||
for pair in self.narrow_phase.contact_pairs() {
|
||||
let other_ch = if pair.collider1 == sensor_ch {
|
||||
pair.collider2
|
||||
} else if pair.collider2 == sensor_ch {
|
||||
pair.collider1
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Find body index for the other collider
|
||||
if let Some(idx) = self.collider_to_body_index(other_ch) {
|
||||
if !self.bodies[idx].removed && idx != sensor_idx {
|
||||
overlaps.push(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check intersection pairs (sensor-specific)
|
||||
for pair in self.narrow_phase.intersection_pairs() {
|
||||
let other_ch = if pair.0 == sensor_ch {
|
||||
pair.1
|
||||
} else if pair.1 == sensor_ch {
|
||||
pair.0
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(idx) = self.collider_to_body_index(other_ch) {
|
||||
if !self.bodies[idx].removed && idx != sensor_idx && !overlaps.contains(&idx) {
|
||||
overlaps.push(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
overlaps
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ───
|
||||
|
|
@ -1611,6 +1933,34 @@ mod tests {
|
|||
assert_eq!(world.get_collision_events().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sensor_collision_event() {
|
||||
let mut world = PhysicsWorld::new(400.0, 400.0);
|
||||
world.set_gravity(0.0, 0.0);
|
||||
|
||||
// Two dynamic bodies that will collide
|
||||
let b0 = world.create_soft_circle(100.0, 200.0, 20.0, 8, 30.0);
|
||||
let b1 = world.create_soft_circle(300.0, 200.0, 20.0, 8, 30.0);
|
||||
world.enable_contact_events(b0);
|
||||
world.enable_contact_events(b1);
|
||||
|
||||
// Push toward each other
|
||||
world.set_velocity(b0, 300.0, 0.0);
|
||||
world.set_velocity(b1, -300.0, 0.0);
|
||||
|
||||
let mut found_event = false;
|
||||
for _ in 0..60 {
|
||||
world.step(1.0 / 60.0);
|
||||
if world.collision_event_count() > 0 {
|
||||
found_event = true;
|
||||
}
|
||||
}
|
||||
assert!(found_event, "Contact events should fire when bodies collide");
|
||||
// Test clear
|
||||
world.clear_collision_events();
|
||||
assert_eq!(world.collision_event_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_point_query() {
|
||||
let mut world = PhysicsWorld::new(400.0, 400.0);
|
||||
|
|
@ -1743,4 +2093,294 @@ mod tests {
|
|||
let bodies_after = world.body_count();
|
||||
assert!(bodies_after > bodies_before, "Emitter should spawn particles: before={} after={}", bodies_before, bodies_after);
|
||||
}
|
||||
|
||||
// ─── v0.9: Sensor Tests ───
|
||||
|
||||
#[test]
|
||||
fn test_sensor_circle_overlap() {
|
||||
let mut world = PhysicsWorld::new(400.0, 400.0);
|
||||
world.set_gravity(0.0, 0.0);
|
||||
|
||||
// Create sensor at center
|
||||
let sensor = world.create_sensor_circle(200.0, 200.0, 50.0);
|
||||
assert!(world.is_sensor(sensor));
|
||||
assert_eq!(world.get_body_type(sensor), 3);
|
||||
|
||||
// Create dynamic body overlapping the sensor
|
||||
let body = world.create_soft_circle(200.0, 200.0, 10.0, 8, 30.0);
|
||||
assert!(!world.is_sensor(body));
|
||||
|
||||
// Step to let physics detect
|
||||
for _ in 0..10 {
|
||||
world.step(1.0 / 60.0);
|
||||
}
|
||||
|
||||
let overlaps = world.get_sensor_overlaps(sensor);
|
||||
assert!(overlaps.contains(&body), "Sensor should detect overlapping body: {:?}", overlaps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sensor_no_collision_response() {
|
||||
let mut world = PhysicsWorld::new(400.0, 400.0);
|
||||
world.set_gravity(0.0, 0.0);
|
||||
|
||||
// Sensor at center
|
||||
let _sensor = world.create_sensor_circle(200.0, 200.0, 50.0);
|
||||
|
||||
// Push body through sensor — it should pass right through
|
||||
let body = world.create_soft_circle(100.0, 200.0, 10.0, 8, 30.0);
|
||||
world.set_velocity(body, 200.0, 0.0);
|
||||
|
||||
for _ in 0..60 {
|
||||
world.step(1.0 / 60.0);
|
||||
}
|
||||
|
||||
let pos = world.get_body_center(body);
|
||||
assert!(pos[0] > 200.0, "Body should pass through sensor without collision: x={}", pos[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sensor_rect_overlap() {
|
||||
let mut world = PhysicsWorld::new(400.0, 400.0);
|
||||
world.set_gravity(0.0, 0.0);
|
||||
|
||||
let sensor = world.create_sensor_rect(200.0, 200.0, 100.0, 100.0);
|
||||
assert!(world.is_sensor(sensor));
|
||||
|
||||
// Body inside sensor bounds
|
||||
let body = world.create_soft_circle(200.0, 200.0, 5.0, 8, 30.0);
|
||||
|
||||
for _ in 0..10 {
|
||||
world.step(1.0 / 60.0);
|
||||
}
|
||||
|
||||
let overlaps = world.get_sensor_overlaps(sensor);
|
||||
assert!(overlaps.contains(&body), "Rect sensor should detect body: {:?}", overlaps);
|
||||
}
|
||||
|
||||
// ─── v0.10: Revolute Motor Joint Tests ───
|
||||
|
||||
#[test]
|
||||
fn test_revolute_joint_rotation() {
|
||||
let mut world = PhysicsWorld::new(400.0, 400.0);
|
||||
world.set_gravity(0.0, 0.0);
|
||||
|
||||
// Anchor body (kinematic) + rotating body (dynamic)
|
||||
let anchor_body = world.create_kinematic_circle(200.0, 200.0, 5.0);
|
||||
let arm_body = world.create_soft_circle(250.0, 200.0, 10.0, 8, 30.0);
|
||||
|
||||
let joint = world.create_revolute_joint(anchor_body, arm_body, 200.0, 200.0);
|
||||
assert_ne!(joint, usize::MAX);
|
||||
|
||||
// Give arm angular velocity
|
||||
world.set_angular_velocity(arm_body, 5.0);
|
||||
|
||||
for _ in 0..30 {
|
||||
world.step(1.0 / 60.0);
|
||||
}
|
||||
|
||||
// Arm should still be near 50 units from anchor (constrained by joint)
|
||||
let pos = world.get_body_center(arm_body);
|
||||
let dx = pos[0] - 200.0;
|
||||
let dy = pos[1] - 200.0;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
assert!(dist > 30.0 && dist < 70.0, "Arm should orbit near anchor: dist={}", dist);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_revolute_motor_velocity() {
|
||||
let mut world = PhysicsWorld::new(400.0, 400.0);
|
||||
world.set_gravity(0.0, 0.0);
|
||||
|
||||
let anchor_body = world.create_kinematic_circle(200.0, 200.0, 5.0);
|
||||
let arm_body = world.create_soft_circle(250.0, 200.0, 10.0, 8, 30.0);
|
||||
|
||||
let joint = world.create_revolute_joint(anchor_body, arm_body, 200.0, 200.0);
|
||||
world.set_joint_motor(joint, 10.0, 1000.0); // 10 rad/s motor
|
||||
|
||||
for _ in 0..60 {
|
||||
world.step(1.0 / 60.0);
|
||||
}
|
||||
|
||||
// After 1 second with motor, arm should have rotated significantly
|
||||
let pos = world.get_body_center(arm_body);
|
||||
// It's no longer at (250, 200) — it has moved
|
||||
let dx = pos[0] - 200.0;
|
||||
let dy = pos[1] - 200.0;
|
||||
let moved = (250.0 - 200.0 - dx).abs() > 5.0 || dy.abs() > 5.0;
|
||||
assert!(moved, "Motor should rotate arm away from initial position: ({}, {})", pos[0], pos[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_revolute_motor_position() {
|
||||
let mut world = PhysicsWorld::new(400.0, 400.0);
|
||||
world.set_gravity(0.0, 0.0);
|
||||
|
||||
let anchor_body = world.create_kinematic_circle(200.0, 200.0, 5.0);
|
||||
let arm_body = world.create_soft_circle(250.0, 200.0, 10.0, 8, 30.0);
|
||||
|
||||
let joint = world.create_revolute_joint(anchor_body, arm_body, 200.0, 200.0);
|
||||
// Drive to 90 degrees (π/2 radians)
|
||||
world.set_joint_motor_position(joint, std::f64::consts::FRAC_PI_2, 500.0, 50.0);
|
||||
|
||||
for _ in 0..120 {
|
||||
world.step(1.0 / 60.0);
|
||||
}
|
||||
|
||||
// After 2 seconds, arm should be roughly above the anchor (y > 200)
|
||||
let pos = world.get_body_center(arm_body);
|
||||
let dy = pos[1] - 200.0;
|
||||
assert!(dy.abs() > 20.0, "Position motor should drive arm toward 90°: y_offset={}", dy);
|
||||
}
|
||||
|
||||
// ─── v0.11: Prismatic Joint Tests ───
|
||||
|
||||
#[test]
|
||||
fn test_prismatic_joint_slides() {
|
||||
let mut world = PhysicsWorld::new(400.0, 400.0);
|
||||
world.set_gravity(0.0, 0.0);
|
||||
|
||||
let anchor = world.create_kinematic_circle(200.0, 200.0, 5.0);
|
||||
let slider = world.create_soft_circle(200.0, 200.0, 10.0, 8, 30.0);
|
||||
|
||||
// Slide along X axis
|
||||
let joint = world.create_prismatic_joint(anchor, slider, 1.0, 0.0);
|
||||
assert_ne!(joint, usize::MAX);
|
||||
|
||||
// Push slider along X
|
||||
world.set_velocity(slider, 100.0, 50.0);
|
||||
|
||||
for _ in 0..60 {
|
||||
world.step(1.0 / 60.0);
|
||||
}
|
||||
|
||||
// Should have moved along X but Y constrained near 200
|
||||
let pos = world.get_body_center(slider);
|
||||
assert!(pos[0] > 220.0, "Slider should move along X: x={}", pos[0]);
|
||||
assert!((pos[1] - 200.0).abs() < 30.0, "Slider Y should be constrained: y={}", pos[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prismatic_limits() {
|
||||
let mut world = PhysicsWorld::new(400.0, 400.0);
|
||||
world.set_gravity(0.0, 0.0);
|
||||
|
||||
let anchor = world.create_kinematic_circle(200.0, 200.0, 5.0);
|
||||
let slider = world.create_soft_circle(200.0, 200.0, 10.0, 8, 30.0);
|
||||
|
||||
let joint = world.create_prismatic_joint(anchor, slider, 1.0, 0.0);
|
||||
world.set_prismatic_limits(joint, -50.0, 50.0);
|
||||
|
||||
// Push hard to the right
|
||||
world.set_velocity(slider, 500.0, 0.0);
|
||||
|
||||
for _ in 0..120 {
|
||||
world.step(1.0 / 60.0);
|
||||
}
|
||||
|
||||
// Should be clamped near 200 + 50 = 250
|
||||
let pos = world.get_body_center(slider);
|
||||
assert!(pos[0] < 270.0, "Prismatic limit should clamp: x={}", pos[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prismatic_motor() {
|
||||
let mut world = PhysicsWorld::new(400.0, 400.0);
|
||||
world.set_gravity(0.0, 0.0);
|
||||
|
||||
let anchor = world.create_kinematic_circle(200.0, 200.0, 5.0);
|
||||
let slider = world.create_soft_circle(200.0, 200.0, 10.0, 8, 30.0);
|
||||
|
||||
let joint = world.create_prismatic_joint(anchor, slider, 1.0, 0.0);
|
||||
world.set_prismatic_motor(joint, 50.0, 500.0); // 50 units/sec
|
||||
|
||||
for _ in 0..60 {
|
||||
world.step(1.0 / 60.0);
|
||||
}
|
||||
|
||||
// Motor should push slider to the right
|
||||
let pos = world.get_body_center(slider);
|
||||
assert!(pos[0] > 210.0, "Motor should drive slider along X: x={}", pos[0]);
|
||||
}
|
||||
|
||||
// ─── v0.13: Sleeping + Rope Tests ───
|
||||
|
||||
#[test]
|
||||
fn test_body_sleep_wake() {
|
||||
let mut world = PhysicsWorld::new(400.0, 400.0);
|
||||
world.set_gravity(0.0, 0.0);
|
||||
|
||||
let body = world.create_soft_circle(200.0, 200.0, 10.0, 8, 30.0);
|
||||
assert!(!world.is_sleeping(body));
|
||||
|
||||
world.sleep_body(body);
|
||||
assert!(world.is_sleeping(body));
|
||||
|
||||
world.wake_body(body);
|
||||
assert!(!world.is_sleeping(body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sleeping_body_doesnt_move() {
|
||||
let mut world = PhysicsWorld::new(400.0, 400.0);
|
||||
world.set_gravity(0.0, 0.0);
|
||||
|
||||
let body = world.create_soft_circle(200.0, 200.0, 10.0, 8, 30.0);
|
||||
world.set_velocity(body, 100.0, 0.0);
|
||||
world.sleep_body(body);
|
||||
|
||||
for _ in 0..30 {
|
||||
world.step(1.0 / 60.0);
|
||||
}
|
||||
|
||||
let pos = world.get_body_center(body);
|
||||
assert!((pos[0] - 200.0).abs() < 1.0, "Sleeping body should not move: x={}", pos[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rope_joint_max_distance() {
|
||||
let mut world = PhysicsWorld::new(400.0, 400.0);
|
||||
world.set_gravity(0.0, 0.0);
|
||||
|
||||
let b0 = world.create_kinematic_circle(200.0, 200.0, 5.0);
|
||||
let b1 = world.create_soft_circle(200.0, 200.0, 10.0, 8, 30.0);
|
||||
|
||||
let joint = world.create_rope_joint(b0, b1, 80.0);
|
||||
assert_ne!(joint, usize::MAX);
|
||||
|
||||
// Push body far away
|
||||
world.set_velocity(b1, 500.0, 0.0);
|
||||
|
||||
for _ in 0..120 {
|
||||
world.step(1.0 / 60.0);
|
||||
}
|
||||
|
||||
// Should be clamped within ~80 units of anchor
|
||||
let pos = world.get_body_center(b1);
|
||||
let dx = pos[0] - 200.0;
|
||||
let dy = pos[1] - 200.0;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
assert!(dist < 100.0, "Rope should limit distance to ~80: dist={}", dist);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rope_joint_allows_close() {
|
||||
let mut world = PhysicsWorld::new(400.0, 400.0);
|
||||
world.set_gravity(0.0, 0.0);
|
||||
|
||||
let b0 = world.create_kinematic_circle(200.0, 200.0, 5.0);
|
||||
let b1 = world.create_soft_circle(230.0, 200.0, 10.0, 8, 30.0);
|
||||
|
||||
world.create_rope_joint(b0, b1, 80.0);
|
||||
|
||||
// Push body toward anchor — rope allows this
|
||||
world.set_velocity(b1, -50.0, 0.0);
|
||||
|
||||
for _ in 0..30 {
|
||||
world.step(1.0 / 60.0);
|
||||
}
|
||||
|
||||
let pos = world.get_body_center(b1);
|
||||
assert!(pos[0] < 230.0, "Rope should allow bodies to get closer: x={}", pos[0]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,10 @@
|
|||
# Changelog
|
||||
|
||||
## [0.9.0] - 2026-03-10
|
||||
|
||||
### Changed
|
||||
- Version alignment with engine v0.9.0
|
||||
|
||||
## [0.5.0] - 2026-03-09
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- **`auth_challenge_message`/`auth_response_message`** — auth handshake builders
|
||||
- **`scramble`/`descramble`** — XOR payload obfuscation
|
||||
- **`decode_recording_metadata`** — decode 24-byte .dsrec metadata header
|
||||
- **`FRAME_AUTH`** constant (0x0F)
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- 4 new tests: auth challenge, auth response, XOR roundtrip, recording metadata decode
|
||||
|
||||
## [0.4.0] - 2026-03-09 — SessionRecorder/Player, adaptive_quality_tier, bandwidth_kbps
|
||||
## [0.3.0] - 2026-03-09 — TypedFrameHeader, recording decoder, compressed pixel builder
|
||||
## [0.2.0] - 2026-03-09 — parse_stream, frame_type_name, is_input_frame, ack_message
|
||||
## [0.1.0] - 2026-02-26 — Initial release
|
||||
- Initial release
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "ds-stream-wasm"
|
||||
version = "0.5.0"
|
||||
version = "0.9.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "WebAssembly codec for DreamStack bitstream protocol"
|
||||
|
|
|
|||
|
|
@ -1,20 +1,17 @@
|
|||
# Changelog
|
||||
|
||||
## [0.9.0] - 2026-03-10
|
||||
|
||||
### Fixed
|
||||
- Unused imports, variables, visibility warnings (zero-warning build)
|
||||
- Doctest annotations
|
||||
|
||||
### Added
|
||||
- 5 ds_hub unit tests (frame constants, signal encoding, batch header, IR push, PanelEvent debug)
|
||||
|
||||
### Test Coverage
|
||||
- **111 tests** (codec 45, relay 33, protocol 28, ds_hub 5)
|
||||
|
||||
## [0.5.0] - 2026-03-09
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- **`FrameType::Auth`** (0x0F) — new frame type for authentication handshake
|
||||
- **`AuthPayload`** struct — phase, nonce, token with encode/decode
|
||||
- **`scramble_payload`/`descramble_payload`** — XOR obfuscation with repeating key
|
||||
- **`RecordingMetadata`** struct — 24-byte header (version, created_at, duration_ms, frame_count, width, height)
|
||||
- **`auth_challenge_frame`/`auth_response_frame`** — auth handshake frame builders
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- 4 new tests: auth payload roundtrip, XOR scramble, recording metadata, auth frame builders
|
||||
|
||||
## [0.4.0] - 2026-03-09 — AudioFormat, AudioHeader, AdaptiveQuality, BandwidthEstimator
|
||||
## [0.3.0] - 2026-03-09 — CompressedPixelFormat, RecordingWriter/Reader, compressed_pixel_frame
|
||||
## [0.2.0] - 2026-03-09 — StreamParser, Ack/AckEvent, compression_ratio
|
||||
## [0.1.0] - 2026-02-26 — Initial release
|
||||
- Initial release
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "ds-stream"
|
||||
version = "0.5.0"
|
||||
version = "0.9.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Universal bitstream streaming — any input to any output"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use crate::protocol::*;
|
|||
/// Handles partial reads: feed arbitrary chunks in, get complete
|
||||
/// `(FrameHeader, Vec<u8>)` tuples out.
|
||||
///
|
||||
/// ```rust
|
||||
/// ```ignore
|
||||
/// let mut parser = StreamParser::new();
|
||||
/// parser.feed(&data_from_websocket);
|
||||
/// while let Some((header, payload)) = parser.next_message() {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
//! which works with the browser previewer over WebSocket.
|
||||
|
||||
use std::net::UdpSocket;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
|
@ -59,7 +59,7 @@ pub struct DsHub {
|
|||
impl DsHub {
|
||||
/// Create a new hub targeting a panel at the given address.
|
||||
///
|
||||
/// ```rust
|
||||
/// ```ignore
|
||||
/// let hub = DsHub::new("192.168.1.100")?;
|
||||
/// hub.push_ir(&ir_json)?;
|
||||
/// hub.send_signal(0, 75)?;
|
||||
|
|
@ -219,3 +219,106 @@ pub enum PanelEvent {
|
|||
Action { node_id: u8, action: u8 },
|
||||
Touch { node_id: u8, event: u8, x: u16, y: u16 },
|
||||
}
|
||||
|
||||
// ─── Tests ───
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frame_constants() {
|
||||
assert_eq!(frame::DS_MAGIC, [0xD5, 0x7A]);
|
||||
assert_eq!(frame::DS_UDP_PORT, 9200);
|
||||
assert_eq!(frame::DS_NOW_SIG, 0x20);
|
||||
assert_eq!(frame::DS_NOW_SIG_BATCH, 0x21);
|
||||
assert_eq!(frame::DS_NOW_TOUCH, 0x30);
|
||||
assert_eq!(frame::DS_NOW_ACTION, 0x31);
|
||||
assert_eq!(frame::DS_NOW_PING, 0xFE);
|
||||
assert_eq!(frame::DS_NOW_PONG, 0xFD);
|
||||
assert_eq!(frame::DS_UDP_IR_PUSH, 0x40);
|
||||
assert_eq!(frame::DS_UDP_IR_FRAG, 0x41);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_update_encoding() {
|
||||
// Verify signal encoding matches the wire format:
|
||||
// [type(1)] [id_lo(1)] [id_hi(1)] [val(4 LE)]
|
||||
let id: u16 = 0x0102;
|
||||
let value: i32 = 0x04030201;
|
||||
let buf: [u8; 7] = [
|
||||
frame::DS_NOW_SIG,
|
||||
(id & 0xFF) as u8,
|
||||
((id >> 8) & 0xFF) as u8,
|
||||
(value & 0xFF) as u8,
|
||||
((value >> 8) & 0xFF) as u8,
|
||||
((value >> 16) & 0xFF) as u8,
|
||||
((value >> 24) & 0xFF) as u8,
|
||||
];
|
||||
assert_eq!(buf[0], 0x20); // SIG type
|
||||
assert_eq!(buf[1], 0x02); // id lo
|
||||
assert_eq!(buf[2], 0x01); // id hi
|
||||
assert_eq!(buf[3], 0x01); // val byte 0
|
||||
assert_eq!(buf[6], 0x04); // val byte 3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_batch_header() {
|
||||
// Batch format: [type(1)] [count(1)] [seq(1)] [signals: count × 6]
|
||||
let signals = vec![
|
||||
SignalUpdate { id: 0, value: 100 },
|
||||
SignalUpdate { id: 1, value: 200 },
|
||||
SignalUpdate { id: 2, value: -50 },
|
||||
];
|
||||
let mut buf = Vec::with_capacity(3 + signals.len() * 6);
|
||||
buf.push(frame::DS_NOW_SIG_BATCH);
|
||||
buf.push(signals.len() as u8);
|
||||
buf.push(0); // seq
|
||||
|
||||
for sig in &signals {
|
||||
buf.extend_from_slice(&sig.id.to_le_bytes());
|
||||
buf.extend_from_slice(&sig.value.to_le_bytes());
|
||||
}
|
||||
|
||||
assert_eq!(buf[0], 0x21); // BATCH type
|
||||
assert_eq!(buf[1], 3); // count
|
||||
assert_eq!(buf.len(), 3 + 3 * 6); // header + 3 signals × 6 bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ir_push_below_mtu() {
|
||||
// IR payloads < 1400 bytes should use DS_UDP_IR_PUSH (single packet)
|
||||
let ir_json = r#"{"type":"text","value":"hello"}"#;
|
||||
assert!(ir_json.len() < 1400);
|
||||
|
||||
let data = ir_json.as_bytes();
|
||||
let mut buf = Vec::with_capacity(6 + data.len());
|
||||
buf.extend_from_slice(&frame::DS_MAGIC);
|
||||
buf.push(frame::DS_UDP_IR_PUSH);
|
||||
buf.push(0);
|
||||
buf.extend_from_slice(&(data.len() as u16).to_le_bytes());
|
||||
buf.extend_from_slice(data);
|
||||
|
||||
assert_eq!(buf[0], 0xD5); // magic[0]
|
||||
assert_eq!(buf[1], 0x7A); // magic[1]
|
||||
assert_eq!(buf[2], 0x40); // IR_PUSH
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_event_debug() {
|
||||
let pong = PanelEvent::Pong(42);
|
||||
let debug = format!("{:?}", pong);
|
||||
assert!(debug.contains("Pong"));
|
||||
assert!(debug.contains("42"));
|
||||
|
||||
let action = PanelEvent::Action { node_id: 5, action: 1 };
|
||||
let debug = format!("{:?}", action);
|
||||
assert!(debug.contains("Action"));
|
||||
|
||||
let touch = PanelEvent::Touch { node_id: 1, event: 0, x: 320, y: 240 };
|
||||
let debug = format!("{:?}", touch);
|
||||
assert!(debug.contains("Touch"));
|
||||
assert!(debug.contains("320"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -285,6 +285,7 @@ impl StateCache {
|
|||
}
|
||||
|
||||
/// Clear all cached state.
|
||||
#[allow(dead_code)]
|
||||
fn clear(&mut self) {
|
||||
self.last_keyframe = None;
|
||||
self.last_signal_sync = None;
|
||||
|
|
@ -469,7 +470,8 @@ pub fn channel_matches(pattern: &str, channel: &str) -> bool {
|
|||
}
|
||||
|
||||
/// Find all channels matching a wildcard pattern.
|
||||
pub(crate) fn find_matching_channels(state: &RelayState, pattern: &str) -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
fn find_matching_channels(state: &RelayState, pattern: &str) -> Vec<String> {
|
||||
state.channels.keys()
|
||||
.filter(|name| channel_matches(pattern, name))
|
||||
.cloned()
|
||||
|
|
@ -805,7 +807,7 @@ async fn handle_connection(
|
|||
};
|
||||
|
||||
// Handle meta requests separately (they don't use WebSocket)
|
||||
if let ConnectionRole::Meta(ref channel_name) = role {
|
||||
if let ConnectionRole::Meta(ref _channel_name) = role {
|
||||
// Meta requests fail the WS handshake, so we'll never reach here
|
||||
// But just in case, close the connection
|
||||
return;
|
||||
|
|
@ -1289,7 +1291,7 @@ async fn handle_peer(
|
|||
|
||||
// Forward frames from this peer → broadcast to all others
|
||||
let channel_for_cache = channel.clone();
|
||||
let channel_name_cache = channel_name.to_string();
|
||||
let _channel_name_cache = channel_name.to_string();
|
||||
while let Some(Ok(msg)) = ws_source.next().await {
|
||||
if let Message::Binary(data) = msg {
|
||||
let data_vec: Vec<u8> = data.into();
|
||||
|
|
@ -1502,6 +1504,40 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── v0.14: Peer + Meta Path Tests ───
|
||||
|
||||
#[test]
|
||||
fn parse_path_peer_default() {
|
||||
match parse_path("/peer") {
|
||||
ConnectionRole::Peer(name) => assert_eq!(name, "default"),
|
||||
_ => panic!("Expected Peer"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_path_peer_named() {
|
||||
match parse_path("/peer/room42") {
|
||||
ConnectionRole::Peer(name) => assert_eq!(name, "room42"),
|
||||
_ => panic!("Expected Peer"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_path_meta_default() {
|
||||
match parse_path("/meta") {
|
||||
ConnectionRole::Meta(name) => assert_eq!(name, "default"),
|
||||
_ => panic!("Expected Meta"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_path_meta_named() {
|
||||
match parse_path("/meta/stats") {
|
||||
ConnectionRole::Meta(name) => assert_eq!(name, "stats"),
|
||||
_ => panic!("Expected Meta"),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Channel State Tests ───
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue