test: cover CLI workspace resolver, date picker, saturating version

Add regression tests for the bugs found in this smoke test:

- resolve_workspace: by-name, by-UUID, unknown-identifier, current-fallback,
  actionable no-workspace message.
- DateTimePicker: selected-day highlight must be month-scoped; committing
  after navigating months uses the selected month, not the viewed one.
- create_task: version is saturating_add on u64::MAX (doesn't panic/wrap).

Also fixes the three pre-existing clippy warnings (WorkspaceMode now uses
#[derive(Default)] + #[default], repository test drops unused binding,
sync test uses struct-update syntax instead of field-reassign-default).
This commit is contained in:
Claude 2026-04-17 16:32:22 +00:00
parent efb4ccaaef
commit a79dcc4617
No known key found for this signature in database
5 changed files with 149 additions and 10 deletions

View file

@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import DateTimePicker from "./DateTimePicker.svelte";
beforeEach(() => {
cleanup();
});
describe("DateTimePicker — selected highlight", () => {
it("only marks the selected day in the month/year that was actually picked", async () => {
const user = userEvent.setup();
// Pick a date in the current month so the component opens on it.
const now = new Date();
const existing = new Date(now.getFullYear(), now.getMonth(), 15, 0, 0, 0).toISOString();
render(DateTimePicker, {
value: existing,
has_time: false,
onchange: vi.fn(),
onclose: vi.fn(),
});
// The "15" button for the current month should be rendered with the
// selected styling (bg-primary).
const day15 = screen.getByRole("button", { name: "15" });
expect(day15.className).toMatch(/bg-primary/);
// Navigate one month forward. The same "15" cell must NOT be marked as
// selected, because the user hasn't picked a day in that month yet.
const nextMonthBtn = screen.getAllByRole("button").find((b) =>
b.querySelector("svg path[d*='M7.21 14.77']"),
) as HTMLElement;
await user.click(nextMonthBtn);
const nextMonth15 = screen.getByRole("button", { name: "15" });
expect(nextMonth15.className).not.toMatch(/bg-primary/);
});
it("commits based on the last-selected month, not the currently-viewed month", async () => {
const user = userEvent.setup();
const onchange = vi.fn();
const onclose = vi.fn();
// Start with April 10 selected (use a fixed month/year so the test is stable).
const existing = new Date(2026, 3, 10, 0, 0, 0).toISOString();
render(DateTimePicker, {
value: existing,
has_time: false,
onchange,
onclose,
});
// Pick the 20th while viewing April.
await user.click(screen.getByRole("button", { name: "20" }));
// Flip to May.
const nextMonthBtn = screen.getAllByRole("button").find((b) =>
b.querySelector("svg path[d*='M7.21 14.77']"),
) as HTMLElement;
await user.click(nextMonthBtn);
// Hit Done.
await user.click(screen.getByRole("button", { name: "Done" }));
expect(onchange).toHaveBeenCalled();
const committed = new Date(onchange.mock.calls[0][0] as string);
// April == month 3 (0-indexed). We navigated to May without reselecting,
// so the committed date must still be April 20.
expect(committed.getMonth()).toBe(3);
expect(committed.getDate()).toBe(20);
expect(committed.getFullYear()).toBe(2026);
});
});

View file

@ -53,3 +53,60 @@ pub fn get_repository(workspace_identifier: Option<String>) -> Result<(TaskRepos
Ok((repo, name)) Ok((repo, name))
} }
#[cfg(test)]
mod tests {
use super::*;
fn make_config_with(ws: &[(&str, &str)]) -> (AppConfig, Vec<String>) {
let mut config = AppConfig::new();
let ids: Vec<String> = ws.iter()
.map(|(name, path)| config.add_workspace(WorkspaceConfig::new(name.to_string(), PathBuf::from(path))))
.collect();
(config, ids)
}
#[test]
fn resolve_by_name() {
let (config, _ids) = make_config_with(&[("dev", "/tmp/dev"), ("home", "/tmp/home")]);
let (id, ws) = resolve_workspace(&config, Some("dev")).unwrap();
assert_eq!(ws.name, "dev");
assert!(config.workspaces.contains_key(&id));
}
#[test]
fn resolve_by_uuid() {
let (config, ids) = make_config_with(&[("dev", "/tmp/dev")]);
let target = ids[0].clone();
let (id, ws) = resolve_workspace(&config, Some(&target)).unwrap();
assert_eq!(id, target);
assert_eq!(ws.name, "dev");
}
#[test]
fn resolve_unknown_identifier_errors() {
let (config, _ids) = make_config_with(&[("dev", "/tmp/dev")]);
let err = resolve_workspace(&config, Some("ghost")).unwrap_err();
assert!(err.to_string().contains("Workspace 'ghost' not found"));
}
#[test]
fn resolve_falls_back_to_current() {
let (mut config, ids) = make_config_with(&[("a", "/tmp/a"), ("b", "/tmp/b")]);
config.set_current_workspace(ids[1].clone()).unwrap();
let (id, ws) = resolve_workspace(&config, None).unwrap();
assert_eq!(id, ids[1]);
assert_eq!(ws.name, "b");
}
#[test]
fn resolve_no_current_gives_actionable_message() {
let config = AppConfig::new();
let err = resolve_workspace(&config, None).unwrap_err();
let msg = err.to_string();
// The message should point the user at the right sub-commands, not
// at the obsolete 'onyx init' suggestion.
assert!(msg.contains("workspace add") || msg.contains("workspace switch"),
"expected actionable message, got: {msg}");
}
}

View file

@ -4,20 +4,15 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum WorkspaceMode { pub enum WorkspaceMode {
#[default]
Local, Local,
Webdav, Webdav,
GoogleTasks, GoogleTasks,
} }
impl Default for WorkspaceMode {
fn default() -> Self {
Self::Local
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceConfig { pub struct WorkspaceConfig {
pub name: String, pub name: String,

View file

@ -157,7 +157,7 @@ mod tests {
// Create a task // Create a task
let task = Task::new("Test Task".to_string()); let task = Task::new("Test Task".to_string());
let created_task = repo.create_task(list.id, task).unwrap(); let _ = repo.create_task(list.id, task).unwrap();
// List tasks // List tasks
let tasks = repo.list_tasks(list.id).unwrap(); let tasks = repo.list_tasks(list.id).unwrap();
@ -165,6 +165,20 @@ mod tests {
assert_eq!(tasks[0].title, "Test Task"); assert_eq!(tasks[0].title, "Test Task");
} }
#[test]
fn test_create_task_saturates_version_at_max() {
let temp_dir = TempDir::new().unwrap();
let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
let list = repo.create_list("L".to_string()).unwrap();
// Simulate a task that is already at u64::MAX. A plain `+=` would
// overflow — saturating_add must clamp.
let mut task = Task::new("max".to_string());
task.version = u64::MAX;
let created = repo.create_task(list.id, task).unwrap();
assert_eq!(created.version, u64::MAX);
}
#[test] #[test]
fn test_update_task() { fn test_update_task() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();

View file

@ -1137,8 +1137,7 @@ mod tests {
#[test] #[test]
fn test_sync_state_save_load_roundtrip() { fn test_sync_state_save_load_roundtrip() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let mut state = SyncState::default(); let mut state = SyncState { last_sync: Some(Utc::now()), ..Default::default() };
state.last_sync = Some(Utc::now());
state.record_file("test.md", "abc123", Some("2026-01-01T00:00:00Z"), 42); state.record_file("test.md", "abc123", Some("2026-01-01T00:00:00Z"), 42);
state.save(temp_dir.path()).unwrap(); state.save(temp_dir.path()).unwrap();