Initial commit: ink-notes (latency-optimized Linux inking app in Rust)
This commit is contained in:
+751
@@ -0,0 +1,751 @@
|
||||
//! Robuste lokale Notizbuch-Persistenz mit atomaren Writes (Crash-Resistenz)
|
||||
//! und semantischer Datenspeicherung (ohne gecachte GPU-Vertizes).
|
||||
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::canvas::InkVertex;
|
||||
use crate::document::{Notebook, Page, PageBackground, Stroke, StylusSample};
|
||||
use crate::filter::OneEuroFilter;
|
||||
use crate::spline::{self, ControlPoint, SplineConfig};
|
||||
|
||||
/// Serialisierbarer Stylus-Abtastpunkt in DIN-A4-Koordinaten.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SampleData {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub p: f32,
|
||||
pub t: f64,
|
||||
}
|
||||
|
||||
impl From<&StylusSample> for SampleData {
|
||||
fn from(s: &StylusSample) -> Self {
|
||||
Self {
|
||||
x: s.position.x,
|
||||
y: s.position.y,
|
||||
p: s.pressure,
|
||||
t: s.timestamp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SampleData> for StylusSample {
|
||||
fn from(d: SampleData) -> Self {
|
||||
Self {
|
||||
position: glam::Vec2::new(d.x, d.y),
|
||||
pressure: d.p,
|
||||
timestamp: d.t,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialisierbare semantische Strichdaten (ohne GPU-Vertizes).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StrokeData {
|
||||
pub id: u64,
|
||||
pub color: [f32; 4],
|
||||
pub samples: Vec<SampleData>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub shape: Option<crate::geometry::RecognizedShape>,
|
||||
}
|
||||
|
||||
impl From<&Stroke> for StrokeData {
|
||||
fn from(stroke: &Stroke) -> Self {
|
||||
Self {
|
||||
id: stroke.id,
|
||||
color: stroke.color,
|
||||
samples: stroke.points.iter().map(SampleData::from).collect(),
|
||||
shape: stroke.shape.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StrokeData {
|
||||
/// Wandelt die semantischen Daten deterministisch zurück in einen `Stroke` um,
|
||||
/// inklusive Wiederherstellung des Triangle-Strip-Vertexpuffers und der AABB.
|
||||
pub fn to_stroke(&self) -> Stroke {
|
||||
let stylus_samples: Vec<StylusSample> = self.samples.iter().copied().map(StylusSample::from).collect();
|
||||
let vertices = if let Some(ref shape) = self.shape {
|
||||
let (_, shape_verts) = crate::geometry::shape_to_mesh(shape, 20.0, self.color);
|
||||
shape_verts
|
||||
} else {
|
||||
reconstruct_stroke_vertices(&stylus_samples, self.color)
|
||||
};
|
||||
let mut stroke = Stroke::new(self.id, stylus_samples, vertices, self.color);
|
||||
stroke.shape = self.shape.clone();
|
||||
stroke
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialisierbare semantische Seitendaten.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PageData {
|
||||
pub id: u64,
|
||||
pub background: PageBackground,
|
||||
pub strokes: Vec<StrokeData>,
|
||||
}
|
||||
|
||||
impl From<&Page> for PageData {
|
||||
fn from(page: &Page) -> Self {
|
||||
Self {
|
||||
id: page.id,
|
||||
background: page.background,
|
||||
strokes: page.strokes.iter().map(StrokeData::from).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PageData {
|
||||
/// Wandelt die Seitendaten in eine runtime-aktive `Page` mit initialisiertem Cache um.
|
||||
pub fn to_page(&self) -> Page {
|
||||
let mut page = Page::new(self.id, self.background);
|
||||
for stroke_data in &self.strokes {
|
||||
page.strokes.push(stroke_data.to_stroke());
|
||||
}
|
||||
page.is_dirty = true;
|
||||
page.rebuild_cache_if_dirty();
|
||||
page.is_loaded = true;
|
||||
page
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadaten des gesamten Notizbuchs (`manifest.json`).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NotebookManifest {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub created_at: u64,
|
||||
pub modified_at: u64,
|
||||
pub current_page_index: usize,
|
||||
pub page_ids: Vec<u64>,
|
||||
pub next_stroke_id: u64,
|
||||
pub next_page_id: u64,
|
||||
}
|
||||
|
||||
impl NotebookManifest {
|
||||
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
Self {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
current_page_index: 0,
|
||||
page_ids: vec![1],
|
||||
next_stroke_id: 1,
|
||||
next_page_id: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mögliche Fehler bei Speicher- und Ladeoperationen.
|
||||
#[derive(Debug)]
|
||||
pub enum StorageError {
|
||||
Io(io::Error),
|
||||
Serialization(serde_json::Error),
|
||||
InvalidManifest(String),
|
||||
CorruptedPage(u64, String),
|
||||
PageNotFound(u64),
|
||||
}
|
||||
|
||||
impl fmt::Display for StorageError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
StorageError::Io(e) => write!(f, "I/O-Fehler: {e}"),
|
||||
StorageError::Serialization(e) => write!(f, "Serialisierungsfehler: {e}"),
|
||||
StorageError::InvalidManifest(msg) => write!(f, "Ungültiges Manifest: {msg}"),
|
||||
StorageError::CorruptedPage(id, msg) => {
|
||||
write!(f, "Beschädigte Seite {id}: {msg}")
|
||||
}
|
||||
StorageError::PageNotFound(id) => write!(f, "Seite {id} nicht gefunden"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for StorageError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
match self {
|
||||
StorageError::Io(e) => Some(e),
|
||||
StorageError::Serialization(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for StorageError {
|
||||
fn from(e: io::Error) -> Self {
|
||||
StorageError::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for StorageError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
StorageError::Serialization(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rekonstruiert deterministisch die GPU-Triangle-Strip-Vertizes aus Stylus-Samples.
|
||||
pub fn reconstruct_stroke_vertices(samples: &[StylusSample], color: [f32; 4]) -> Vec<InkVertex> {
|
||||
if samples.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if samples.len() == 1 {
|
||||
let pt = samples[0];
|
||||
let radius = spline::calculate_radius(pt.pressure, 2.5, 10.0);
|
||||
let segments = 8;
|
||||
let mut vertices = Vec::with_capacity((segments + 1) * 2);
|
||||
for i in 0..=segments {
|
||||
let angle = (i as f32 / segments as f32) * std::f32::consts::TAU;
|
||||
let offset = glam::Vec2::new(angle.cos() * radius, angle.sin() * radius);
|
||||
vertices.push(InkVertex::new(pt.position.x, pt.position.y, color));
|
||||
vertices.push(InkVertex::new(pt.position.x + offset.x, pt.position.y + offset.y, color));
|
||||
}
|
||||
return vertices;
|
||||
}
|
||||
|
||||
let mut filter = OneEuroFilter::default();
|
||||
let mut control_points: Vec<ControlPoint> = Vec::with_capacity(samples.len());
|
||||
|
||||
for s in samples {
|
||||
let (fx, fy, fp) = filter.filter(s.position.x, s.position.y, s.pressure, s.timestamp);
|
||||
let pt = ControlPoint::new(fx, fy, fp);
|
||||
if let Some(last) = control_points.last() {
|
||||
if (pt.position - last.position).length_squared() < 1.0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
control_points.push(pt);
|
||||
}
|
||||
|
||||
let config = SplineConfig {
|
||||
min_radius: 2.5,
|
||||
max_radius: 10.0,
|
||||
samples_per_segment: 16,
|
||||
};
|
||||
let raw_strip = spline::generate_triangle_strip(&control_points, &config);
|
||||
raw_strip
|
||||
.into_iter()
|
||||
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Schreibt Inhalt atomar auf die Festplatte (über temporäre Datei + `std::fs::rename`).
|
||||
pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> {
|
||||
let dir = path.parent().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::NotFound, "Übergeordnetes Verzeichnis nicht gefunden")
|
||||
})?;
|
||||
fs::create_dir_all(dir)?;
|
||||
|
||||
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
|
||||
let random_suffix: u64 = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos() as u64;
|
||||
let temp_filename = format!("{filename}.tmp.{}.{random_suffix}", std::process::id());
|
||||
let temp_path = dir.join(temp_filename);
|
||||
|
||||
// In temporäre Datei schreiben und auf Datenträger flushen
|
||||
{
|
||||
let mut file = File::create(&temp_path)?;
|
||||
file.write_all(content)?;
|
||||
file.sync_all()?;
|
||||
}
|
||||
|
||||
// Atomares Rename
|
||||
if let Err(e) = fs::rename(&temp_path, path) {
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialisiert einen Wert als formatiertes JSON und schreibt ihn atomar.
|
||||
pub fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StorageError> {
|
||||
let bytes = serde_json::to_vec_pretty(value)?;
|
||||
atomic_write_file(path, &bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Speichert das Manifest atomar in `manifest.json`.
|
||||
pub fn save_manifest(notebook_dir: &Path, manifest: &NotebookManifest) -> Result<(), StorageError> {
|
||||
let path = notebook_dir.join("manifest.json");
|
||||
atomic_write_json(&path, manifest)
|
||||
}
|
||||
|
||||
/// Lädt das Manifest aus `manifest.json`.
|
||||
pub fn load_manifest(notebook_dir: &Path) -> Result<NotebookManifest, StorageError> {
|
||||
let path = notebook_dir.join("manifest.json");
|
||||
if !path.exists() {
|
||||
return Err(StorageError::InvalidManifest(format!(
|
||||
"Manifestdatei nicht gefunden: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let mut file = File::open(&path)?;
|
||||
let mut json_str = String::new();
|
||||
file.read_to_string(&mut json_str)?;
|
||||
let manifest: NotebookManifest = serde_json::from_str(&json_str)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Speichert eine Seite atomar in `page_<id>.json`.
|
||||
pub fn save_page(notebook_dir: &Path, page: &PageData) -> Result<(), StorageError> {
|
||||
let path = notebook_dir.join(format!("page_{}.json", page.id));
|
||||
atomic_write_json(&path, page)
|
||||
}
|
||||
|
||||
/// Lädt eine Seite aus `page_<id>.json`.
|
||||
pub fn load_page(notebook_dir: &Path, page_id: u64) -> Result<PageData, StorageError> {
|
||||
let path = notebook_dir.join(format!("page_{page_id}.json"));
|
||||
if !path.exists() {
|
||||
return Err(StorageError::PageNotFound(page_id));
|
||||
}
|
||||
|
||||
let mut file = File::open(&path)?;
|
||||
let mut json_str = String::new();
|
||||
file.read_to_string(&mut json_str)?;
|
||||
|
||||
match serde_json::from_str::<PageData>(&json_str) {
|
||||
Ok(data) => Ok(data),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[Storage-Warnung] Seite {page_id} in '{}' ist beschädigt: {e}",
|
||||
path.display()
|
||||
);
|
||||
Err(StorageError::CorruptedPage(page_id, e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Speichert ein gesamtes Notebook synchron auf die Festplatte (z. B. initial oder beim Flush).
|
||||
pub fn save_notebook(
|
||||
notebook_dir: &Path,
|
||||
notebook: &Notebook,
|
||||
manifest: &mut NotebookManifest,
|
||||
) -> Result<(), StorageError> {
|
||||
fs::create_dir_all(notebook_dir)?;
|
||||
|
||||
manifest.page_ids = notebook.pages.iter().map(|p| p.id).collect();
|
||||
manifest.current_page_index = notebook.current_page_index;
|
||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||
manifest.next_page_id = notebook.next_page_id;
|
||||
manifest.modified_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
// Alle bereits geladenen Seiten speichern
|
||||
for page in ¬ebook.pages {
|
||||
if page.is_loaded {
|
||||
let page_data = PageData::from(page);
|
||||
save_page(notebook_dir, &page_data)?;
|
||||
}
|
||||
}
|
||||
|
||||
save_manifest(notebook_dir, manifest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lädt ein Notizbuch aus einem Ordner (erste Seite sofort, Folgeseiten als Platzhalter für Lazy Loading).
|
||||
pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest), StorageError> {
|
||||
let manifest = load_manifest(notebook_dir)?;
|
||||
if manifest.page_ids.is_empty() {
|
||||
let nb = Notebook::new();
|
||||
return Ok((nb, manifest));
|
||||
}
|
||||
|
||||
let mut pages = Vec::with_capacity(manifest.page_ids.len());
|
||||
|
||||
// Erste Seite sofort laden und initialisieren
|
||||
let first_id = manifest.page_ids[0];
|
||||
let first_page = match load_page(notebook_dir, first_id) {
|
||||
Ok(data) => data.to_page(),
|
||||
Err(e) => {
|
||||
eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite.");
|
||||
Page::new(first_id, PageBackground::Grid5mm)
|
||||
}
|
||||
};
|
||||
pages.push(first_page);
|
||||
|
||||
// Folgeseiten als ungeladene Platzhalter anlegen (Lazy Loading)
|
||||
for &page_id in &manifest.page_ids[1..] {
|
||||
pages.push(Page::new_unloaded(page_id, PageBackground::Grid5mm));
|
||||
}
|
||||
|
||||
let notebook = Notebook::from_pages(
|
||||
pages,
|
||||
manifest.current_page_index,
|
||||
manifest.next_stroke_id,
|
||||
manifest.next_page_id,
|
||||
);
|
||||
|
||||
Ok((notebook, manifest))
|
||||
}
|
||||
|
||||
/// Stellt sicher, dass die Seite am Index `page_idx` geladen ist (Lazy Loading beim Seitenwechsel).
|
||||
pub fn ensure_page_loaded(
|
||||
notebook_dir: &Path,
|
||||
notebook: &mut Notebook,
|
||||
page_idx: usize,
|
||||
) -> Result<(), StorageError> {
|
||||
if page_idx >= notebook.pages.len() {
|
||||
return Err(StorageError::PageNotFound(page_idx as u64));
|
||||
}
|
||||
|
||||
if notebook.pages[page_idx].is_loaded {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let page_id = notebook.pages[page_idx].id;
|
||||
match load_page(notebook_dir, page_id) {
|
||||
Ok(data) => {
|
||||
notebook.pages[page_idx] = data.to_page();
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[Storage] Fehler beim Lazy Loading von Seite {page_id}: {e}");
|
||||
// Ersatzseite als geladen markieren, damit die App nicht hängt
|
||||
notebook.pages[page_idx].is_loaded = true;
|
||||
notebook.pages[page_idx].rebuild_cache_if_dirty();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gibt das Standard-Verzeichnis für Notizbücher zurück (gemäß XDG-Spezifikation).
|
||||
pub fn default_storage_dir() -> PathBuf {
|
||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||
if !xdg.is_empty() {
|
||||
return PathBuf::from(xdg).join("ink-notes").join("notebooks");
|
||||
}
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
if !home.is_empty() {
|
||||
return PathBuf::from(home)
|
||||
.join(".local")
|
||||
.join("share")
|
||||
.join("ink-notes")
|
||||
.join("notebooks");
|
||||
}
|
||||
}
|
||||
PathBuf::from("./data/notebooks")
|
||||
}
|
||||
|
||||
/// Zusammenfassende Metadaten eines Notizbuchs für die Dokumentenliste.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NotebookSummary {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub page_count: usize,
|
||||
pub modified_at: u64,
|
||||
}
|
||||
|
||||
/// Durchsucht das Basis-Verzeichnis nach allen vorhandenen Notizbüchern.
|
||||
pub fn list_notebooks(base_dir: &Path) -> Vec<NotebookSummary> {
|
||||
let mut list = Vec::new();
|
||||
let Ok(entries) = fs::read_dir(base_dir) else {
|
||||
return list;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let manifest_path = path.join("manifest.json");
|
||||
if manifest_path.exists() {
|
||||
if let Ok(manifest) = load_manifest(&path) {
|
||||
list.push(NotebookSummary {
|
||||
id: manifest.id,
|
||||
title: manifest.title,
|
||||
page_count: manifest.page_ids.len(),
|
||||
modified_at: manifest.modified_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nach letztem Änderungsdatum absteigend sortieren (neueste zuerst)
|
||||
list.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
list
|
||||
}
|
||||
|
||||
/// Erstellt ein neues Notizbuch mit dem angegebenen Titel und initialisiert Seite 1.
|
||||
pub fn create_new_notebook(
|
||||
base_dir: &Path,
|
||||
title: &str,
|
||||
) -> Result<(Notebook, NotebookManifest, PathBuf), StorageError> {
|
||||
fs::create_dir_all(base_dir)?;
|
||||
|
||||
let now_ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
|
||||
let clean_slug: String = title
|
||||
.chars()
|
||||
.filter_map(|c| {
|
||||
if c.is_alphanumeric() {
|
||||
Some(c.to_ascii_lowercase())
|
||||
} else if c == ' ' || c == '-' || c == '_' {
|
||||
Some('_')
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let clean_slug = clean_slug.trim_matches('_');
|
||||
|
||||
let id = if clean_slug.is_empty() {
|
||||
format!("nb_{now_ts}")
|
||||
} else {
|
||||
format!("{clean_slug}_{now_ts}")
|
||||
};
|
||||
|
||||
let notebook_dir = base_dir.join(&id);
|
||||
fs::create_dir_all(¬ebook_dir)?;
|
||||
|
||||
let notebook = Notebook::new();
|
||||
let mut manifest = NotebookManifest::new(&id, title);
|
||||
|
||||
save_notebook(¬ebook_dir, ¬ebook, &mut manifest)?;
|
||||
|
||||
Ok((notebook, manifest, notebook_dir))
|
||||
}
|
||||
|
||||
/// Benennt ein Notizbuch um und aktualisiert das Manifest atomar auf der Festplatte.
|
||||
pub fn rename_notebook(
|
||||
notebook_dir: &Path,
|
||||
manifest: &mut NotebookManifest,
|
||||
new_title: &str,
|
||||
) -> Result<(), StorageError> {
|
||||
manifest.title = new_title.to_string();
|
||||
manifest.modified_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
save_manifest(notebook_dir, manifest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Löscht ein Notizbuch vollständig vom Datenträger.
|
||||
pub fn delete_notebook(notebook_dir: &Path) -> Result<(), StorageError> {
|
||||
if notebook_dir.exists() {
|
||||
fs::remove_dir_all(notebook_dir)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::autosave::AutosaveController;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_atomic_save_and_reload() {
|
||||
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
|
||||
let path = temp_dir.path();
|
||||
|
||||
let mut page = Page::new(42, PageBackground::DotGrid);
|
||||
let sample1 = StylusSample {
|
||||
position: glam::Vec2::new(120.0, 340.0),
|
||||
pressure: 0.45,
|
||||
timestamp: 1.0,
|
||||
};
|
||||
let sample2 = StylusSample {
|
||||
position: glam::Vec2::new(135.0, 360.0),
|
||||
pressure: 0.85,
|
||||
timestamp: 1.02,
|
||||
};
|
||||
let sample3 = StylusSample {
|
||||
position: glam::Vec2::new(160.0, 390.0),
|
||||
pressure: 0.65,
|
||||
timestamp: 1.05,
|
||||
};
|
||||
|
||||
let stroke = Stroke::new(
|
||||
101,
|
||||
vec![sample1, sample2, sample3],
|
||||
vec![
|
||||
InkVertex::new(118.0, 340.0, [0.1, 0.2, 0.8, 1.0]),
|
||||
InkVertex::new(122.0, 340.0, [0.1, 0.2, 0.8, 1.0]),
|
||||
InkVertex::new(132.0, 360.0, [0.1, 0.2, 0.8, 1.0]),
|
||||
InkVertex::new(138.0, 360.0, [0.1, 0.2, 0.8, 1.0]),
|
||||
InkVertex::new(158.0, 390.0, [0.1, 0.2, 0.8, 1.0]),
|
||||
InkVertex::new(162.0, 390.0, [0.1, 0.2, 0.8, 1.0]),
|
||||
],
|
||||
[0.1, 0.2, 0.8, 1.0],
|
||||
);
|
||||
page.add_stroke(stroke);
|
||||
|
||||
// Speichern
|
||||
let page_data = PageData::from(&page);
|
||||
let save_res = save_page(path, &page_data);
|
||||
assert!(save_res.is_ok(), "Speichern der Seite muss gelingen");
|
||||
|
||||
// Datei existiert
|
||||
let file_path = path.join("page_42.json");
|
||||
assert!(file_path.exists());
|
||||
|
||||
// Neu laden
|
||||
let load_res = load_page(path, 42);
|
||||
assert!(load_res.is_ok(), "Laden der Seite muss gelingen");
|
||||
let loaded_data = load_res.unwrap();
|
||||
let loaded_page = loaded_data.to_page();
|
||||
|
||||
// Überprüfungen
|
||||
assert_eq!(loaded_page.id, 42);
|
||||
assert_eq!(loaded_page.background, PageBackground::DotGrid);
|
||||
assert_eq!(loaded_page.strokes.len(), 1);
|
||||
|
||||
let loaded_stroke = &loaded_page.strokes[0];
|
||||
assert_eq!(loaded_stroke.id, 101);
|
||||
assert_eq!(loaded_stroke.color, [0.1, 0.2, 0.8, 1.0]);
|
||||
assert_eq!(loaded_stroke.points.len(), 3);
|
||||
|
||||
for (orig, loaded) in page.strokes[0].points.iter().zip(loaded_stroke.points.iter()) {
|
||||
assert!((orig.position.x - loaded.position.x).abs() < 1e-4);
|
||||
assert!((orig.position.y - loaded.position.y).abs() < 1e-4);
|
||||
assert!((orig.pressure - loaded.pressure).abs() < 1e-4);
|
||||
assert!((orig.timestamp - loaded.timestamp).abs() < 1e-4);
|
||||
}
|
||||
|
||||
// Vertizes wurden deterministisch regeneriert
|
||||
assert!(!loaded_stroke.vertices.is_empty());
|
||||
assert!(!loaded_page.vertex_cache.is_empty());
|
||||
assert!(loaded_page.is_loaded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_corrupted_file_recovery() {
|
||||
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
|
||||
let path = temp_dir.path();
|
||||
|
||||
// Beschädigtes, unvollständiges JSON manuell schreiben
|
||||
let corrupted_file = path.join("page_99.json");
|
||||
let truncated_json = br#"{"id": 99, "background": "Grid5mm", "strokes": [{"id": 1, "color": [0.1, 0.2"#;
|
||||
fs::write(&corrupted_file, truncated_json).expect("Konnte Datei nicht schreiben");
|
||||
|
||||
// Laden muss einen abgefangenen CorruptedPage-Fehler liefern und darf nicht panicken
|
||||
let load_res = load_page(path, 99);
|
||||
assert!(load_res.is_err(), "Laden beschädigter Daten muss fehlschlagen");
|
||||
|
||||
match load_res.unwrap_err() {
|
||||
StorageError::CorruptedPage(id, msg) => {
|
||||
assert_eq!(id, 99);
|
||||
assert!(!msg.is_empty());
|
||||
}
|
||||
other => panic!("Erwartete CorruptedPage-Fehler, erhielt: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debounce_timing() {
|
||||
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
|
||||
let notebook_dir = temp_dir.path().to_path_buf();
|
||||
|
||||
let debounce_duration = Duration::from_millis(150);
|
||||
let mut controller = AutosaveController::new(notebook_dir.clone(), debounce_duration);
|
||||
let manifest = NotebookManifest::new("test_nb", "Test Notizbuch");
|
||||
|
||||
let mut page = Page::new(1, PageBackground::Grid5mm);
|
||||
|
||||
// 10 schnelle aufeinanderfolgende Änderungen (alle 5 ms, deutlich kürzer als 150 ms Debounce)
|
||||
for i in 0..10 {
|
||||
let sample = StylusSample {
|
||||
position: glam::Vec2::new(10.0 + i as f32, 20.0 + i as f32),
|
||||
pressure: 0.5,
|
||||
timestamp: i as f64 * 0.01,
|
||||
};
|
||||
let stroke = Stroke::new(
|
||||
i + 1,
|
||||
vec![sample],
|
||||
vec![InkVertex::new(10.0, 20.0, [0.0, 0.0, 0.0, 1.0])],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
);
|
||||
page.add_stroke(stroke);
|
||||
|
||||
controller.mark_page_dirty(&page, &manifest);
|
||||
controller.update();
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
|
||||
// Während der schnellen Änderungen (ca. 50 ms vergangen) darf noch KEIN Schreibvorgang erfolgt sein!
|
||||
assert_eq!(
|
||||
controller.total_page_writes(),
|
||||
0,
|
||||
"Vor Ablauf des Debounce-Fensters darf noch kein Disk-Write stattgefunden haben"
|
||||
);
|
||||
|
||||
// Jetzt warten wir, bis das Debounce-Fenster abgelaufen ist (> 150 ms)
|
||||
std::thread::sleep(Duration::from_millis(180));
|
||||
controller.update();
|
||||
|
||||
// Flush abwarten
|
||||
controller.flush();
|
||||
|
||||
// Es darf genau EIN tatsächlicher Schreibvorgang für die Seite stattgefunden haben
|
||||
assert_eq!(
|
||||
controller.total_page_writes(),
|
||||
1,
|
||||
"10 schnelle Änderungen müssen in genau 1 gebündelten Schreibvorgang münden"
|
||||
);
|
||||
|
||||
// Verifiziere, dass die Datei existiert und alle 10 Striche enthält
|
||||
let loaded = load_page(¬ebook_dir, 1).expect("Seite muss lesbar sein");
|
||||
assert_eq!(loaded.strokes.len(), 10);
|
||||
|
||||
controller.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_notebook_management() {
|
||||
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
|
||||
let base_dir = temp_dir.path();
|
||||
|
||||
// 1. Erstes Notizbuch anlegen
|
||||
let (nb1, mut m1, dir1) = create_new_notebook(base_dir, "Mathe Vorlesung")
|
||||
.expect("Erstellen von Notizbuch 1 muss klappen");
|
||||
assert_eq!(m1.title, "Mathe Vorlesung");
|
||||
assert_eq!(nb1.pages.len(), 1);
|
||||
assert!(dir1.exists());
|
||||
|
||||
// 2. Zweites Notizbuch anlegen
|
||||
let (_nb2, m2, dir2) = create_new_notebook(base_dir, "Skizzen & Entwürfe")
|
||||
.expect("Erstellen von Notizbuch 2 muss klappen");
|
||||
assert_eq!(m2.title, "Skizzen & Entwürfe");
|
||||
assert!(dir2.exists());
|
||||
|
||||
// 3. Auflisten
|
||||
let summaries = list_notebooks(base_dir);
|
||||
assert_eq!(summaries.len(), 2);
|
||||
assert!(summaries.iter().any(|s| s.id == m1.id && s.title == "Mathe Vorlesung"));
|
||||
assert!(summaries.iter().any(|s| s.id == m2.id && s.title == "Skizzen & Entwürfe"));
|
||||
|
||||
// 4. Umbenennen
|
||||
rename_notebook(&dir1, &mut m1, "Höhere Mathematik")
|
||||
.expect("Umbenennen muss klappen");
|
||||
assert_eq!(m1.title, "Höhere Mathematik");
|
||||
|
||||
let updated_summaries = list_notebooks(base_dir);
|
||||
assert!(updated_summaries.iter().any(|s| s.id == m1.id && s.title == "Höhere Mathematik"));
|
||||
|
||||
// 5. Löschen
|
||||
delete_notebook(&dir2).expect("Löschen muss klappen");
|
||||
assert!(!dir2.exists());
|
||||
|
||||
let final_summaries = list_notebooks(base_dir);
|
||||
assert_eq!(final_summaries.len(), 1);
|
||||
assert_eq!(final_summaries[0].id, m1.id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user