feat: FTPS, AppError, Site Manager, tests, multi-window, auto-clear, CI/CD, virtual scroll

- FTPS: реализован FtpsClient через suppaftp + async-native-tls
- AppError: кастомный enum с Serialize вместо Result<T, String>
- Site Manager UI: создание/редактирование/удаление сайтов
- Тесты: 53 тестов (domain, storage, transfer, fs, remote)
- Multi-window: новое окно + сохранение геометрии
- Auto-clear: воркер чистит завершённые задачи по таймеру
- CI/CD: 3-ступенчатый пайплайн (lint→build→test) с кэшированием
- Virtual scroll: fileTable с рендером только видимых строк
- UI: исправлены close-кнопки, color-scheme для select, скриншоты в README
This commit is contained in:
loki5512344 2026-07-11 00:04:13 +02:00
parent 545dd4ed0b
commit e188fbef36
Signed by: boba
GPG key ID: 253067914055423B
52 changed files with 5188 additions and 523 deletions

View file

@ -101,3 +101,117 @@ pub fn move_into(src_path: &str, dest_dir: &str) -> Result<()> {
fs::rename(src_path, dest)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn test_dir() -> std::path::PathBuf {
let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("wherry-test-{}-{}", std::process::id(), n));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn test_list_empty_dir() {
let dir = test_dir();
let entries = list(&dir.to_string_lossy()).unwrap();
assert!(entries.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_mkdir_and_list() {
let dir = test_dir();
let sub = dir.join("subdir");
mkdir(&sub.to_string_lossy()).unwrap();
let entries = list(&dir.to_string_lossy()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "subdir");
assert_eq!(entries[0].kind, EntryKind::Dir);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_rename_file() {
let dir = test_dir();
let old = dir.join("old.txt");
let new = dir.join("new.txt");
std::fs::write(&old, "hello").unwrap();
rename(&old.to_string_lossy(), &new.to_string_lossy()).unwrap();
assert!(!old.exists());
assert!(new.exists());
assert_eq!(std::fs::read_to_string(&new).unwrap(), "hello");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_delete_file() {
let dir = test_dir();
let file = dir.join("delete_me.txt");
std::fs::write(&file, "content").unwrap();
delete(&file.to_string_lossy()).unwrap();
assert!(!file.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_delete_dir() {
let dir = test_dir();
let sub = dir.join("subdir");
std::fs::create_dir(&sub).unwrap();
delete(&sub.to_string_lossy()).unwrap();
assert!(!sub.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_home_dir() {
let home = home_dir();
assert!(!home.is_empty());
}
#[test]
fn test_move_into() {
let dir = test_dir();
let src = dir.join("source.txt");
let dest_dir = dir.join("dest");
std::fs::create_dir(&dest_dir).unwrap();
std::fs::write(&src, "move me").unwrap();
move_into(&src.to_string_lossy(), &dest_dir.to_string_lossy()).unwrap();
assert!(!src.exists());
assert!(dest_dir.join("source.txt").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_list_sorting() {
let dir = test_dir();
std::fs::write(dir.join("a.txt"), "").unwrap();
std::fs::create_dir(dir.join("b_dir")).unwrap();
std::fs::write(dir.join("c.txt"), "").unwrap();
let entries = list(&dir.to_string_lossy()).unwrap();
// dirs first, then files alphabetically
assert_eq!(entries[0].name, "b_dir");
assert_eq!(entries[0].kind, EntryKind::Dir);
assert_eq!(entries[1].name, "a.txt");
assert_eq!(entries[2].name, "c.txt");
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -23,3 +23,81 @@ impl RemoteRegistry {
self.connections.lock().unwrap().remove(id);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::file_entry::{EntryKind, FileEntry};
use crate::protocols::ProgressAction;
struct MockFs;
#[async_trait::async_trait]
impl RemoteFs for MockFs {
async fn list(&self, _path: &str) -> anyhow::Result<Vec<FileEntry>> {
Ok(vec![])
}
async fn upload_with_progress(
&self,
_local: &str,
_remote: &str,
_on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
) -> anyhow::Result<()> {
Ok(())
}
async fn download_with_progress(
&self,
_remote: &str,
_local: &str,
_on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
) -> anyhow::Result<()> {
Ok(())
}
async fn mkdir(&self, _path: &str) -> anyhow::Result<()> {
Ok(())
}
async fn rename(&self, _from: &str, _to: &str) -> anyhow::Result<()> {
Ok(())
}
async fn delete(&self, _path: &str) -> anyhow::Result<()> {
Ok(())
}
async fn stat(&self, _path: &str) -> anyhow::Result<FileEntry> {
Ok(FileEntry {
name: "test".into(),
path: "/test".into(),
kind: EntryKind::File,
size: None,
modified: None,
permissions: None,
})
}
}
#[test]
fn test_insert_get_remove() {
let registry = RemoteRegistry::default();
let mock = Arc::new(MockFs);
registry.insert("conn-1".into(), mock.clone());
assert!(registry.get("conn-1").is_some());
registry.remove("conn-1");
assert!(registry.get("conn-1").is_none());
}
#[test]
fn test_get_nonexistent() {
let registry = RemoteRegistry::default();
assert!(registry.get("unknown").is_none());
}
#[test]
fn test_insert_multiple() {
let registry = RemoteRegistry::default();
registry.insert("a".into(), Arc::new(MockFs));
registry.insert("b".into(), Arc::new(MockFs));
assert!(registry.get("a").is_some());
assert!(registry.get("b").is_some());
}
}