v1.0.0 — Hexagonal architecture rewrite
All checks were successful
CI / ci (push) Successful in 7m16s

Restructure the monolithic 252-line main.rs into a 10-crate workspace
with clean hexagonal architecture, swappable adapters, and a production-
ready deployment pipeline.

Backend architecture:
- domain: Canvas, Color/Position/PixelUpdate value objects, port traits
  (CanvasStore, CanvasPersistence, EventBroadcaster), BroadcastEvent
- application: use cases (place_pixel, get_state, save/restore snapshot,
  connect/disconnect), AppState with Arc snapshot cache
- config: AppConfig structs + ConfigSource trait
- api-types: shared DTOs, event name constants
- adapters: config-env, canvas-file, http-axum (rust-embed), socketio,
  websocket — all behind port traits, swappable via feature flags
- server: composition root with graceful shutdown (SIGTERM/SIGINT)

Frontend:
- Transport abstraction: Socket.IO and native WebSocket via VITE_TRANSPORT
- Canvas zoom/pan with mouse wheel, pinch-to-zoom, and +/- buttons
- ImageData rendering (~50x faster than fillRect loop)
- Touch support, responsive CSS scaling, mobile-friendly layout
- OG/Twitter Card meta tags for rich link previews

Production:
- Docker: musl static build on scratch — 2.73MB image
- CI workflows for Gitea and GitHub Actions (fmt, clippy, test, Docker push)
- deploy.sh for private registry
- 39 unit tests across domain and application
- Zero unwraps, zero unsafe, graceful error handling with tracing
- Periodic canvas snapshots with rotation, restored on startup
- All config via environment variables with typed defaults
This commit is contained in:
2026-08-18 01:41:40 +02:00
parent e9bea5e1e5
commit f652785acb
85 changed files with 4240 additions and 3736 deletions

View File

@@ -0,0 +1,125 @@
use domain::{Canvas, Color, DomainError, Position};
macro_rules! pos {
($x:expr, $y:expr) => {
Position::new($x, $y)
};
}
macro_rules! color {
($v:expr) => {
Color::new($v)
};
}
macro_rules! assert_pixel {
($canvas:expr, $x:expr, $y:expr, $expected:expr) => {{
let (x, y): (u32, u32) = ($x, $y);
let idx = y as usize * $canvas.width() as usize + x as usize;
let expected = color!($expected);
assert_eq!($canvas.pixels()[idx], expected, "pixel at ({x}, {y})");
}};
}
fn small_canvas() -> Canvas {
Canvas::new(10, 10)
}
#[test]
fn new_canvas_is_all_white() {
let canvas = small_canvas();
assert_eq!(canvas.pixels().len(), 100);
assert!(canvas.pixels().iter().all(|&c| c == Color::white()));
}
#[test]
fn dimensions_match_construction() {
let canvas = Canvas::new(42, 17);
assert_eq!(canvas.width(), 42);
assert_eq!(canvas.height(), 17);
assert_eq!(canvas.pixels().len(), 42 * 17);
}
#[test]
fn place_pixel_updates_correct_position() {
let mut canvas = small_canvas();
let update = canvas.place_pixel(pos!(3, 4), color!(0xFF0000)).unwrap();
assert_pixel!(canvas, 3, 4, 0xFF0000);
assert_eq!(update.position(), pos!(3, 4));
assert_eq!(update.color(), color!(0xFF0000));
}
#[test]
fn place_pixel_does_not_affect_neighbors() {
let mut canvas = small_canvas();
canvas.place_pixel(pos!(5, 5), color!(0xFF)).unwrap();
for (x, y) in [(4, 5), (6, 5), (5, 4), (5, 6)] {
assert_pixel!(canvas, x, y, 0xFFFFFFFF);
}
}
#[test]
fn place_pixel_overwrites_previous() {
let mut canvas = small_canvas();
canvas.place_pixel(pos!(0, 0), color!(0xAA)).unwrap();
canvas.place_pixel(pos!(0, 0), color!(0xBB)).unwrap();
assert_pixel!(canvas, 0, 0, 0xBB);
}
#[test]
fn place_pixel_at_boundary() {
let mut canvas = small_canvas();
assert!(canvas.place_pixel(pos!(9, 9), color!(0xFF)).is_ok());
assert!(canvas.place_pixel(pos!(0, 0), color!(0xFF)).is_ok());
assert!(canvas.place_pixel(pos!(9, 0), color!(0xFF)).is_ok());
assert!(canvas.place_pixel(pos!(0, 9), color!(0xFF)).is_ok());
}
#[test]
fn place_pixel_out_of_bounds() {
let mut canvas = small_canvas();
for (x, y) in [(10, 0), (0, 10), (10, 10), (100, 100)] {
let result = canvas.place_pixel(pos!(x, y), color!(0xFF));
assert!(
matches!(result, Err(DomainError::PixelOutOfBounds(_))),
"({x}, {y}) should be out of bounds"
);
}
}
#[test]
fn from_pixels_with_correct_size() {
let pixels = vec![color!(0xAA); 25];
let canvas = Canvas::from_pixels(5, 5, pixels).unwrap();
assert_eq!(canvas.width(), 5);
assert_eq!(canvas.height(), 5);
assert!(canvas.pixels().iter().all(|&c| c == color!(0xAA)));
}
#[test]
fn from_pixels_with_wrong_size() {
let too_few = vec![color!(0); 10];
let too_many = vec![color!(0); 30];
for pixels in [too_few, too_many] {
assert!(
matches!(
Canvas::from_pixels(5, 5, pixels),
Err(DomainError::InvalidCanvasData { .. })
),
"should reject pixel vec that doesn't match dimensions"
);
}
}
#[test]
fn from_pixels_preserves_content() {
let mut pixels = vec![Color::white(); 9];
pixels[4] = color!(0xFF0000); // center pixel of 3x3
let canvas = Canvas::from_pixels(3, 3, pixels).unwrap();
assert_pixel!(canvas, 1, 1, 0xFF0000);
assert_pixel!(canvas, 0, 0, 0xFFFFFFFF);
}