NewLife/NewLife.NovaDb

重构存储文件布局与目录管理,增强健壮性与测试

- 移除 TableDirectory,新增 TableFileManager,统一表文件命名与分片/索引管理,所有表文件平铺于数据库目录
- DatabaseDirectory 适配新布局,ListTables 自动识别表名,支持分片归并
- FileHeader/PageHeader 增强校验与异常处理,严格序列化/反序列化
- MmfPager/PageCache 增强参数校验、线程安全、命中统计与 LRU 逻辑
- DataTypeExtensions/DefaultDataCodec 编解码更健壮,支持可空类型,异常信息更丰富
- 大量补充和完善单元测试,覆盖边界、异常、并发等场景
- 文档同步更新,确保实现与设计一致
- 为后续表引擎、事务、WAL 等模块开发打下坚实基础
智能大石头 authored at 2026-02-19 00:37:11
773a43c
Tree
1 Parent(s) 88355e1
Summary: 23 changed files with 2679 additions and 453 deletions.
Modified +8 -7
Modified +20 -9
Modified +2 -1
Modified +7 -0
Modified +105 -29
Modified +59 -68
Modified +46 -41
Modified +67 -60
Modified +41 -26
Modified +40 -33
Deleted +0 -133
NewLife.NovaDb/Storage/TableDirectory.cs
Added +201 -0
Added +0 -0
Modified +149 -0
Added +147 -0
Added +150 -0
Added +150 -0
Modified +249 -11
Modified +179 -3
Modified +290 -15
Modified +240 -17
Modified +213 -0
Added +316 -0
Modified +8 -7
diff --git "a/Doc/\345\256\236\347\216\260\346\211\247\350\241\214\350\256\241\345\210\222.md" "b/Doc/\345\256\236\347\216\260\346\211\247\350\241\214\350\256\241\345\210\222.md"
index 4aadefb..771c1d7 100644
--- "a/Doc/\345\256\236\347\216\260\346\211\247\350\241\214\350\256\241\345\210\222.md"
+++ "b/Doc/\345\256\236\347\216\260\346\211\247\350\241\214\350\256\241\345\210\222.md"
@@ -38,7 +38,7 @@
 | 模块 | 目录 | 职责 |
 |------|------|------|
 | **Core** | `Core/` | 配置(`DbOptions`)、错误码(`ErrorCode`)、数据类型映射(`DataType`)、编解码(`IDataCodec`)、异常(`NovaException`) |
-| **Storage** | `Storage/` | 文件布局(`DatabaseDirectory`/`TableDirectory`)、MMF(`MmfPager`)、页/块(`FileHeader`/`PageHeader`)、页缓存(`PageCache`) |
+| **Storage** | `Storage/` | 文件布局(`DatabaseDirectory`/`TableFileManager`)、MMF(`MmfPager`)、页/块(`FileHeader`/`PageHeader`)、页缓存(`PageCache`) |
 | **WAL** | `WAL/` | 日志写入(`WalWriter`)、记录格式(`WalRecord`)、崩溃恢复(`WalRecovery`) |
 | **Tx** | `Tx/` | 事务管理(`TransactionManager`)、MVCC 行版本(`RowVersion`)、事务(`Transaction`) |
 | **Engine.Nova** | `Engine/` | 表模型(`NovaTable`/`TableSchema`)、SkipList 索引、热/冷索引(`HotIndexManager`/`ColdIndexDirectory`)、分片(`ShardManager`/`ShardInfo`) |
@@ -90,16 +90,17 @@
 
 #### 3) 文件夹即数据库 + 文件布局(M1)✅
 
-**目标**:把"表文件组"落地到文件命名与目录结构。
+**目标**:把"表文件组"落地到文件命名规则。
 
 - `DatabaseDirectory`:创建/打开/列举库 ✅
-- `TableDirectory`:创建/打开/列举表 ✅
+- `TableFileManager`:表文件路径管理(替代原 TableDirectory) ✅
 - 文件组命名约定 ✅:
-  - 数据分片:`{TableName}/{ShardId}.data`
-  - 索引文件:`{TableName}/{IndexName}.idx`
-  - WAL:`{TableName}/{ShardId}.wal`
+  - 数据文件:`{TableName}.data`(无分片)/ `{TableName}_{ShardId}.data`(有分片)
+  - 索引文件:`{TableName}.idx`(主键)/ `{TableName}_{IndexName}.idx`(二级索引)
+  - WAL 文件:`{TableName}.wal`(无分片)/ `{TableName}_{ShardId}.wal`(有分片)
+  - 所有文件平铺在数据库目录下,无需为每表创建独立目录
 
-**完成状态**:✅ 可创建空库/空表,目录结构符合约定(`DatabaseDirectoryTests`)。
+**完成状态**:✅ 可创建空库/表文件,文件命名符合约定(`DatabaseDirectoryTests`、`TableFileManagerTests`)。
 
 ---
 
Modified +20 -9
diff --git "a/Doc/\346\236\266\346\236\204\350\256\276\350\256\241\346\226\207\346\241\243.md" "b/Doc/\346\236\266\346\236\204\350\256\276\350\256\241\346\226\207\346\241\243.md"
index 3b160e9..4c70614 100644
--- "a/Doc/\346\236\266\346\236\204\350\256\276\350\256\241\346\226\207\346\241\243.md"
+++ "b/Doc/\346\236\266\346\236\204\350\256\276\350\256\241\346\226\207\346\241\243.md"
@@ -28,7 +28,7 @@ NewLife.NovaDb 是一个纯 C# 实现的混合数据库引擎,运行于 .NET 8
 │          WalWriter │ WalRecord │ WalRecovery                         │
 ├──────────────────────────────────────────────────────────────────────┤
 │                      存储层 (Storage)                                │
-│  MmfPager │ PageCache │ FileHeader │ PageHeader │ DatabaseDirectory   │
+│  MmfPager │ PageCache │ FileHeader │ PageHeader │ TableFileManager    │
 ├──────────────────────────────────────────────────────────────────────┤
 │                      核心层 (Core)                                   │
 │  DbOptions │ DataType │ IDataCodec │ NovaException │ ErrorCode      │
@@ -98,16 +98,27 @@ Cluster → WAL → Storage → Core
 
 ```
 {DatabasePath}/
-├── nova.db              # 数据库元数据文件 (FileHeader)
-├── _sys/                # 系统表目录
-├── {TableName}/         # 表目录
-│   ├── 0.data           # 数据分片文件
-│   ├── 0.wal            # WAL 日志文件
-│   ├── PK.idx           # 主键索引文件
-│   └── {IndexName}.idx  # 二级索引文件
+├── nova.db                      # 数据库元数据文件 (FileHeader)
+├── _sys_tables.data             # 系统表数据文件
+├── _sys_tables.wal              # 系统表 WAL 文件
+├── _sys_tables.idx              # 系统表主键索引
+├── {TableName}.data             # 表数据文件
+├── {TableName}.wal              # 表 WAL 日志文件
+├── {TableName}.idx              # 表主键索引文件
+├── {TableName}_{IndexName}.idx  # 表二级索引文件
+├── {TableName}_0.data           # 分片 0 数据文件(启用分片时)
+├── {TableName}_0.wal            # 分片 0 WAL 文件
+├── {TableName}_0.idx            # 分片 0 主键索引
+├── {TableName}_1.data           # 分片 1 数据文件
 └── ...
 ```
 
+**说明**:
+- 每表文件组使用相同的表名前缀,通过后缀区分类型(`.data`/`.wal`/`.idx`)
+- 二级索引文件命名为 `{TableName}_{IndexName}.idx`
+- 启用分片时,文件名格式为 `{TableName}_{ShardId}.{ext}`
+- 无需为每个表单独创建目录,所有文件平铺在数据库目录下
+
 ### 3.2 文件头 (`FileHeader`)
 
 32 字节固定大小,包含:
@@ -127,7 +138,7 @@ Cluster → WAL → Storage → Core
 ### 3.4 目录管理
 
 - **DatabaseDirectory**: 数据库级别管理(创建/打开/列举/删除)
-- **TableDirectory**: 表级别管理(文件组创建/文件列举)
+- **TableFileManager**: 表文件路径管理(文件命名规则/路径生成/分片列举)
 
 ## 4. WAL 日志层 (WAL)
 
Modified +2 -1
diff --git "a/Doc/\351\234\200\346\261\202\350\247\204\346\240\274\350\257\264\346\230\216\344\271\246.md" "b/Doc/\351\234\200\346\261\202\350\247\204\346\240\274\350\257\264\346\230\216\344\271\246.md"
index beca016..0ae75e2 100644
--- "a/Doc/\351\234\200\346\261\202\350\247\204\346\240\274\350\257\264\346\230\216\344\271\246.md"
+++ "b/Doc/\351\234\200\346\261\202\350\247\204\346\240\274\350\257\264\346\230\216\344\271\246.md"
@@ -35,8 +35,9 @@
     *   **自动文件切分**:当单个物理文件达到阈值(按大小/时间/行数等策略)后,自动切分为多个数据分片(Shard),以支持无上限存储。
     *   **冷热加载原则**:表数据极少读取或不读取时,不应占用显著内存;只有频繁读取的热点范围,才提升为内存热索引。
 *   **索引文件 (核心)**:
-    *   **多索引多文件**:一张表每个索引(主键/二级索引/唯一索引等)对应独立索引文件(例如 `PK.idx`, `{IndexName}.idx`),便于独立加载、淘汰与重建。
+    *   **多索引多文件**:一张表每个索引(主键/二级索引/唯一索引等)对应独立索引文件(主键索引为 `{TableName}.idx`,二级索引为 `{TableName}_{IndexName}.idx`),便于独立加载、淘汰与重建。
     *   **idx 不是内存跳表节点镜像**:`.idx` 存储可持久化的“目录 + 块/页”结构(稀疏目录锚点 -> 块偏移),用于按需 MMF 映射与快速定位;内存 SkipList 用作热缓存结构,可从 `.idx` 按需重建热点区域。
+    *   **文件组命名**:每表文件组使用相同表名前缀,通过后缀区分(`.data`/`.wal`/`.idx`),分片时使用 `{TableName}_{ShardId}.{ext}` 格式,无需独立目录。
 *   **适用场景**:通用 CRUD,配置表,业务订单,用户数据。
 
 ### 3.2 时序引擎:Flux Engine (时序 + MQ)
Modified +7 -0
diff --git a/NewLife.NovaDb/Core/DataType.cs b/NewLife.NovaDb/Core/DataType.cs
index 3876ed4..42a7753 100644
--- a/NewLife.NovaDb/Core/DataType.cs
+++ b/NewLife.NovaDb/Core/DataType.cs
@@ -64,6 +64,13 @@ public static class DataTypeExtensions
     /// <returns>数据类型</returns>
     public static DataType FromClrType(Type type)
     {
+        if (type == null)
+            throw new ArgumentNullException(nameof(type));
+
+        // 处理可空类型
+        if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>))
+            type = Nullable.GetUnderlyingType(type)!;
+
         if (type == typeof(Boolean)) return DataType.Boolean;
         if (type == typeof(Int32)) return DataType.Int32;
         if (type == typeof(Int64)) return DataType.Int64;
Modified +105 -29
diff --git a/NewLife.NovaDb/Core/IDataCodec.cs b/NewLife.NovaDb/Core/IDataCodec.cs
index bb1dc33..c073502 100644
--- a/NewLife.NovaDb/Core/IDataCodec.cs
+++ b/NewLife.NovaDb/Core/IDataCodec.cs
@@ -33,23 +33,31 @@ public class DefaultDataCodec : IDataCodec
     public Byte[] Encode(Object? value, DataType dataType)
     {
         if (value == null)
-        {
-            // NULL 值标记为长度 -1
             return BitConverter.GetBytes(-1);
-        }
 
-        return dataType switch
+        try
         {
-            DataType.Boolean => BitConverter.GetBytes((Boolean)value),
-            DataType.Int32 => BitConverter.GetBytes((Int32)value),
-            DataType.Int64 => BitConverter.GetBytes((Int64)value),
-            DataType.Double => BitConverter.GetBytes((Double)value),
-            DataType.Decimal => EncodeDecimal((Decimal)value),
-            DataType.String => EncodeString((String)value),
-            DataType.Binary => EncodeByteArray((Byte[])value),
-            DataType.DateTime => BitConverter.GetBytes(((DateTime)value).Ticks),
-            _ => throw new NotSupportedException($"Unsupported data type: {dataType}")
-        };
+            return dataType switch
+            {
+                DataType.Boolean => BitConverter.GetBytes((Boolean)value),
+                DataType.Int32 => BitConverter.GetBytes((Int32)value),
+                DataType.Int64 => BitConverter.GetBytes((Int64)value),
+                DataType.Double => BitConverter.GetBytes((Double)value),
+                DataType.Decimal => EncodeDecimal((Decimal)value),
+                DataType.String => EncodeString((String)value),
+                DataType.Binary => EncodeByteArray((Byte[])value),
+                DataType.DateTime => BitConverter.GetBytes(((DateTime)value).Ticks),
+                _ => throw new NotSupportedException($"Unsupported data type: {dataType}")
+            };
+        }
+        catch (InvalidCastException ex)
+        {
+            throw new NovaException(
+                ErrorCode.InvalidArgument,
+                $"Cannot encode value of type {value.GetType().Name} as {dataType}",
+                ex
+            );
+        }
     }
 
     /// <summary>从二进制解码值</summary>
@@ -59,28 +67,51 @@ public class DefaultDataCodec : IDataCodec
     /// <returns>解码后的值</returns>
     public Object? Decode(Byte[] buffer, Int32 offset, DataType dataType)
     {
-        // 检查是否为 NULL
-        if (buffer.Length >= offset + 4)
+        if (buffer == null)
+            throw new ArgumentNullException(nameof(buffer));
+        if (offset < 0)
+            throw new ArgumentOutOfRangeException(nameof(offset), "Offset cannot be negative");
+        if (offset >= buffer.Length)
+            throw new ArgumentOutOfRangeException(nameof(offset), "Offset exceeds buffer length");
+
+        // 检查 NULL 标记(String/Binary 类型使用 -1 长度表示 NULL)
+        if (dataType is DataType.String or DataType.Binary)
         {
+            if (buffer.Length < offset + 4)
+                throw new ArgumentException("Buffer too short to read length prefix");
+
             var length = BitConverter.ToInt32(buffer, offset);
             if (length == -1)
-            {
                 return null;
-            }
         }
 
-        return dataType switch
+        try
         {
-            DataType.Boolean => BitConverter.ToBoolean(buffer, offset),
-            DataType.Int32 => BitConverter.ToInt32(buffer, offset),
-            DataType.Int64 => BitConverter.ToInt64(buffer, offset),
-            DataType.Double => BitConverter.ToDouble(buffer, offset),
-            DataType.Decimal => DecodeDecimal(buffer, offset),
-            DataType.String => DecodeString(buffer, offset),
-            DataType.Binary => DecodeByteArray(buffer, offset),
-            DataType.DateTime => new DateTime(BitConverter.ToInt64(buffer, offset)),
-            _ => throw new NotSupportedException($"Unsupported data type: {dataType}")
-        };
+            return dataType switch
+            {
+                DataType.Boolean => DecodeBoolean(buffer, offset),
+                DataType.Int32 => DecodeInt32(buffer, offset),
+                DataType.Int64 => DecodeInt64(buffer, offset),
+                DataType.Double => DecodeDouble(buffer, offset),
+                DataType.Decimal => DecodeDecimal(buffer, offset),
+                DataType.String => DecodeString(buffer, offset),
+                DataType.Binary => DecodeByteArray(buffer, offset),
+                DataType.DateTime => DecodeDateTime(buffer, offset),
+                _ => throw new NotSupportedException($"Unsupported data type: {dataType}")
+            };
+        }
+        catch (ArgumentException)
+        {
+            throw;
+        }
+        catch (Exception ex) when (ex is not NotSupportedException)
+        {
+            throw new NovaException(
+                ErrorCode.ParseFailed,
+                $"Failed to decode {dataType} at offset {offset}",
+                ex
+            );
+        }
     }
 
     /// <summary>获取编码后的长度</summary>
@@ -116,8 +147,45 @@ public class DefaultDataCodec : IDataCodec
         return buffer;
     }
 
+    private Boolean DecodeBoolean(Byte[] buffer, Int32 offset)
+    {
+        if (buffer.Length < offset + 1)
+            throw new ArgumentException($"Buffer too short to read Boolean (need {offset + 1} bytes, got {buffer.Length})");
+        return BitConverter.ToBoolean(buffer, offset);
+    }
+
+    private Int32 DecodeInt32(Byte[] buffer, Int32 offset)
+    {
+        if (buffer.Length < offset + 4)
+            throw new ArgumentException($"Buffer too short to read Int32 (need {offset + 4} bytes, got {buffer.Length})");
+        return BitConverter.ToInt32(buffer, offset);
+    }
+
+    private Int64 DecodeInt64(Byte[] buffer, Int32 offset)
+    {
+        if (buffer.Length < offset + 8)
+            throw new ArgumentException($"Buffer too short to read Int64 (need {offset + 8} bytes, got {buffer.Length})");
+        return BitConverter.ToInt64(buffer, offset);
+    }
+
+    private Double DecodeDouble(Byte[] buffer, Int32 offset)
+    {
+        if (buffer.Length < offset + 8)
+            throw new ArgumentException($"Buffer too short to read Double (need {offset + 8} bytes, got {buffer.Length})");
+        return BitConverter.ToDouble(buffer, offset);
+    }
+
+    private DateTime DecodeDateTime(Byte[] buffer, Int32 offset)
+    {
+        if (buffer.Length < offset + 8)
+            throw new ArgumentException($"Buffer too short to read DateTime (need {offset + 8} bytes, got {buffer.Length})");
+        return new DateTime(BitConverter.ToInt64(buffer, offset));
+    }
+
     private Decimal DecodeDecimal(Byte[] buffer, Int32 offset)
     {
+        if (buffer.Length < offset + 16)
+            throw new ArgumentException($"Buffer too short to read Decimal (need {offset + 16} bytes, got {buffer.Length})");
         var bits = new Int32[4];
         Buffer.BlockCopy(buffer, offset, bits, 0, 16);
         return new Decimal(bits);
@@ -148,7 +216,15 @@ public class DefaultDataCodec : IDataCodec
 
     private Byte[] DecodeByteArray(Byte[] buffer, Int32 offset)
     {
+        if (buffer.Length < offset + 4)
+            throw new ArgumentException($"Buffer too short to read ByteArray length (need {offset + 4} bytes, got {buffer.Length})");
+
         var length = BitConverter.ToInt32(buffer, offset);
+        if (length < 0)
+            throw new ArgumentException($"Invalid byte array length: {length}");
+        if (buffer.Length < offset + 4 + length)
+            throw new ArgumentException($"Buffer too short to read ByteArray (need {offset + 4 + length} bytes, got {buffer.Length})");
+
         var result = new Byte[length];
         Buffer.BlockCopy(buffer, offset + 4, result, 0, length);
         return result;
Modified +59 -68
diff --git a/NewLife.NovaDb/Storage/DatabaseDirectory.cs b/NewLife.NovaDb/Storage/DatabaseDirectory.cs
index 25351f5..54e6778 100644
--- a/NewLife.NovaDb/Storage/DatabaseDirectory.cs
+++ b/NewLife.NovaDb/Storage/DatabaseDirectory.cs
@@ -1,46 +1,44 @@
-using NewLife.NovaDb.Core;
+using NewLife.NovaDb.Core;
 
 namespace NewLife.NovaDb.Storage;
 
-/// <summary>
-/// 数据库目录管理(文件夹即数据库)
-/// </summary>
+/// <summary>数据库目录管理(文件夹即数据库)</summary>
+/// <remarks>
+/// 数据库目录结构:
+/// - nova.db: 元数据文件(32 字节 FileHeader)
+/// - {TableName}.data/.wal/.idx: 表文件组(平铺在目录下)
+/// - _sys_*.data: 系统表文件
+/// </remarks>
 public class DatabaseDirectory
 {
     private readonly String _basePath;
     private readonly DbOptions _options;
 
-    /// <summary>
-    /// 数据库路径
-    /// </summary>
+    /// <summary>数据库路径</summary>
     public String Path => _basePath;
 
-    /// <summary>
-    /// 系统表路径
-    /// </summary>
-    public String SystemPath => System.IO.Path.Combine(_basePath, "_sys");
-
+    /// <summary>实例化数据库目录管理器</summary>
+    /// <param name="path">数据库目录路径</param>
+    /// <param name="options">数据库配置</param>
     public DatabaseDirectory(String path, DbOptions options)
     {
         _basePath = path ?? throw new ArgumentNullException(nameof(path));
         _options = options ?? throw new ArgumentNullException(nameof(options));
+
+        if (String.IsNullOrWhiteSpace(path))
+            throw new ArgumentException("Database path cannot be empty", nameof(path));
     }
 
-    /// <summary>
-    /// 创建数据库(创建目录结构)
-    /// </summary>
+    /// <summary>创建数据库(创建目录并写入元数据文件)</summary>
+    /// <exception cref="NovaException">目录已存在时抛出</exception>
     public void Create()
     {
         if (Directory.Exists(_basePath))
-        {
-            throw new NovaException(ErrorCode.InvalidArgument,
-                $"Database directory already exists: {_basePath}");
-        }
+            throw new NovaException(ErrorCode.InvalidArgument, $"Database directory already exists: {_basePath}");
 
         Directory.CreateDirectory(_basePath);
-        Directory.CreateDirectory(SystemPath);
 
-        // 创建数据库元数据文件
+        // 写入元数据文件
         var metaPath = System.IO.Path.Combine(_basePath, "nova.db");
         var header = new FileHeader
         {
@@ -54,35 +52,25 @@ public class DatabaseDirectory
         File.WriteAllBytes(metaPath, header.ToBytes());
     }
 
-    /// <summary>
-    /// 打开数据库(验证目录存在且格式正确)
-    /// </summary>
+    /// <summary>打开数据库(验证目录存在且格式正确)</summary>
+    /// <exception cref="NovaException">目录不存在、元数据文件缺失或版本不兼容时抛出</exception>
     public void Open()
     {
         if (!Directory.Exists(_basePath))
-        {
-            throw new NovaException(ErrorCode.InvalidArgument,
-                $"Database directory does not exist: {_basePath}");
-        }
+            throw new NovaException(ErrorCode.InvalidArgument, $"Database directory does not exist: {_basePath}");
 
         var metaPath = System.IO.Path.Combine(_basePath, "nova.db");
         if (!File.Exists(metaPath))
-        {
-            throw new NovaException(ErrorCode.FileCorrupted,
-                $"Database metadata file not found: {metaPath}");
-        }
+            throw new NovaException(ErrorCode.FileCorrupted, $"Database metadata file not found: {metaPath}");
 
         // 验证文件格式
         var metaBytes = File.ReadAllBytes(metaPath);
         var header = FileHeader.FromBytes(metaBytes);
 
         if (header.Version > 1)
-        {
-            throw new NovaException(ErrorCode.IncompatibleFileFormat,
-                $"Unsupported database version: {header.Version}");
-        }
+            throw new NovaException(ErrorCode.IncompatibleFileFormat, $"Unsupported database version: {header.Version}");
 
-        // 验证配置一致性
+        // 配置一致性警告
         var currentHash = ComputeOptionsHash(_options);
         if (header.OptionsHash != currentHash && header.PageSize != _options.PageSize)
         {
@@ -91,57 +79,60 @@ public class DatabaseDirectory
         }
     }
 
-    /// <summary>
-    /// 列举所有表
-    /// </summary>
+    /// <summary>列举所有用户表</summary>
+    /// <remarks>通过扫描数据库目录下的 .data 文件提取表名,自动排除系统表</remarks>
+    /// <returns>按字母排序的表名集合</returns>
     public IEnumerable<String> ListTables()
     {
         if (!Directory.Exists(_basePath))
-        {
             yield break;
+
+        var tableNames = new HashSet<String>();
+        var dataFiles = Directory.GetFiles(_basePath, "*.data");
+
+        foreach (var file in dataFiles)
+        {
+            var fileName = System.IO.Path.GetFileNameWithoutExtension(file);
+
+            // 跳过系统表
+            if (fileName.StartsWith("_sys"))
+                continue;
+
+            // 提取表名:{TableName}.data 或 {TableName}_{ShardId}.data
+            var parts = fileName.Split('_');
+            var tableName = parts[0];
+
+            if (!String.IsNullOrEmpty(tableName))
+                tableNames.Add(tableName);
         }
 
-        foreach (var dir in Directory.GetDirectories(_basePath))
+        foreach (var name in tableNames.OrderBy(x => x))
         {
-            var tableName = System.IO.Path.GetFileName(dir);
-            if (!tableName.StartsWith("_"))
-            {
-                yield return tableName;
-            }
+            yield return name;
         }
     }
 
-    /// <summary>
-    /// 获取表目录
-    /// </summary>
-    public TableDirectory GetTableDirectory(String tableName)
+    /// <summary>获取表文件管理器</summary>
+    /// <param name="tableName">表名</param>
+    /// <returns>表文件管理器</returns>
+    /// <exception cref="ArgumentException">表名为空时抛出</exception>
+    public TableFileManager GetTableFileManager(String tableName)
     {
         if (String.IsNullOrWhiteSpace(tableName))
-        {
             throw new ArgumentException("Table name cannot be empty", nameof(tableName));
-        }
 
-        var tablePath = System.IO.Path.Combine(_basePath, tableName);
-        return new TableDirectory(tablePath, tableName, _options);
+        return new TableFileManager(_basePath, tableName, _options);
     }
 
-    /// <summary>
-    /// 删除数据库
-    /// </summary>
+    /// <summary>删除数据库(删除整个目录)</summary>
     public void Drop()
     {
         if (Directory.Exists(_basePath))
-        {
             Directory.Delete(_basePath, recursive: true);
-        }
     }
 
-    /// <summary>
-    /// 计算配置哈希
-    /// </summary>
-    private UInt32 ComputeOptionsHash(DbOptions options)
-    {
-        // 简单的哈希计算(使用页大小作为主要标识)
-        return (UInt32)options.PageSize;
-    }
+    /// <summary>计算配置哈希</summary>
+    /// <param name="options">数据库配置</param>
+    /// <returns>配置哈希值</returns>
+    private UInt32 ComputeOptionsHash(DbOptions options) => (UInt32)options.PageSize;
 }
Modified +46 -41
diff --git a/NewLife.NovaDb/Storage/FileHeader.cs b/NewLife.NovaDb/Storage/FileHeader.cs
index 6670bce..e27c17d 100644
--- a/NewLife.NovaDb/Storage/FileHeader.cs
+++ b/NewLife.NovaDb/Storage/FileHeader.cs
@@ -1,46 +1,45 @@
 namespace NewLife.NovaDb.Storage;
 
-/// <summary>
-/// 文件头结构(每个 .data/.idx/.wal 文件的开头)
-/// </summary>
+/// <summary>文件头结构(每个 .data/.idx/.wal 文件的开头,固定 32 字节)</summary>
+/// <remarks>
+/// 文件头布局(32 字节):
+/// - 0-3: Magic Number (0x4E4F5641 "NOVA")
+/// - 4-5: Version (当前版本 1)
+/// - 6: FileType (1=Data, 2=Index, 3=Wal)
+/// - 7: Reserved
+/// - 8-11: PageSize (页大小,字节)
+/// - 12-19: CreatedAt (创建时间,UTC Ticks)
+/// - 20-23: OptionsHash (配置哈希)
+/// - 24-31: Reserved (预留扩展)
+/// </remarks>
 public class FileHeader
 {
-    /// <summary>
-    /// 魔数标识(固定 "NOVA" 0x4E4F5641)
-    /// </summary>
+    /// <summary>魔数标识(固定 "NOVA" 0x4E4F5641)</summary>
     public const UInt32 MagicNumber = 0x4E4F5641;
 
-    /// <summary>
-    /// 文件格式版本号
-    /// </summary>
+    /// <summary>文件头固定大小(32 字节)</summary>
+    public const Int32 HeaderSize = 32;
+
+    /// <summary>文件格式版本号</summary>
     public UInt16 Version { get; set; } = 1;
 
-    /// <summary>
-    /// 文件类型(Data/Index/Wal)
-    /// </summary>
+    /// <summary>文件类型(Data/Index/Wal)</summary>
     public FileType FileType { get; set; }
 
-    /// <summary>
-    /// 页大小(字节)
-    /// </summary>
+    /// <summary>页大小(字节)</summary>
     public UInt32 PageSize { get; set; }
 
-    /// <summary>
-    /// 创建时间(UTC Ticks)
-    /// </summary>
+    /// <summary>创建时间(UTC Ticks)</summary>
     public Int64 CreatedAt { get; set; }
 
-    /// <summary>
-    /// 配置哈希(用于验证配置一致性)
-    /// </summary>
+    /// <summary>配置哈希(用于验证配置一致性)</summary>
     public UInt32 OptionsHash { get; set; }
 
-    /// <summary>
-    /// 序列化为字节数组(固定 32 字节)
-    /// </summary>
+    /// <summary>序列化为字节数组(固定 32 字节)</summary>
+    /// <returns>32 字节的文件头数据</returns>
     public Byte[] ToBytes()
     {
-        var buffer = new Byte[32];
+        var buffer = new Byte[HeaderSize];
         var offset = 0;
 
         // Magic Number (4 bytes)
@@ -69,46 +68,52 @@ public class FileHeader
         Buffer.BlockCopy(BitConverter.GetBytes(OptionsHash), 0, buffer, offset, 4);
         offset += 4;
 
-        // Reserved (8 bytes)
-        // offset += 8;
+        // Reserved (8 bytes) - 自动为 0
 
         return buffer;
     }
 
-    /// <summary>
-    /// 从字节数组反序列化
-    /// </summary>
+    /// <summary>从字节数组反序列化</summary>
+    /// <param name="buffer">包含文件头数据的字节数组(至少 32 字节)</param>
+    /// <returns>反序列化的文件头对象</returns>
+    /// <exception cref="ArgumentNullException">buffer 为 null</exception>
+    /// <exception cref="ArgumentException">buffer 长度不足 32 字节</exception>
+    /// <exception cref="Core.NovaException">魔数验证失败或文件类型无效</exception>
     public static FileHeader FromBytes(Byte[] buffer)
     {
-        if (buffer.Length < 32)
-        {
-            throw new ArgumentException("Buffer too short for FileHeader");
-        }
+        if (buffer == null)
+            throw new ArgumentNullException(nameof(buffer));
+
+        if (buffer.Length < HeaderSize)
+            throw new ArgumentException($"Buffer too short for FileHeader, expected {HeaderSize} bytes, got {buffer.Length}", nameof(buffer));
 
         var offset = 0;
 
-        // Magic Number
+        // Magic Number 验证
         var magic = BitConverter.ToUInt32(buffer, offset);
         offset += 4;
         if (magic != MagicNumber)
-        {
-            throw new Core.NovaException(Core.ErrorCode.FileCorrupted,
-                $"Invalid magic number: 0x{magic:X8}");
-        }
+            throw new Core.NovaException(Core.ErrorCode.FileCorrupted, $"Invalid magic number: 0x{magic:X8}, expected 0x{MagicNumber:X8}");
 
         // Version
         var version = BitConverter.ToUInt16(buffer, offset);
         offset += 2;
 
         // FileType
-        var fileType = (FileType)buffer[offset++];
+        var fileTypeByte = buffer[offset++];
+        if (!Enum.IsDefined(typeof(FileType), fileTypeByte))
+            throw new Core.NovaException(Core.ErrorCode.FileCorrupted, $"Invalid file type: {fileTypeByte}");
+
+        var fileType = (FileType)fileTypeByte;
 
         // Reserved
         offset += 1;
 
-        // PageSize
+        // PageSize 验证
         var pageSize = BitConverter.ToUInt32(buffer, offset);
         offset += 4;
+        if (pageSize == 0 || pageSize > 64 * 1024)
+            throw new Core.NovaException(Core.ErrorCode.FileCorrupted, $"Invalid page size: {pageSize}, must be between 1 and 65536");
 
         // CreatedAt
         var createdAt = BitConverter.ToInt64(buffer, offset);
Modified +67 -60
diff --git a/NewLife.NovaDb/Storage/MmfPager.cs b/NewLife.NovaDb/Storage/MmfPager.cs
index 5bb5c6d..167b256 100644
--- a/NewLife.NovaDb/Storage/MmfPager.cs
+++ b/NewLife.NovaDb/Storage/MmfPager.cs
@@ -3,9 +3,15 @@ using NewLife.NovaDb.Core;
 
 namespace NewLife.NovaDb.Storage;
 
-/// <summary>
-/// 基于内存映射文件的分页访问器
-/// </summary>
+/// <summary>基于内存映射文件的分页访问器</summary>
+/// <remarks>
+/// 提供文件级别的分页读写能力,支持:
+/// - 自动写入/验证 FileHeader
+/// - 按 PageId 随机读写页面
+/// - 可选的 CRC32 校验和验证
+/// - 文件按需扩展
+/// 页面偏移 = FileHeader(32B) + PageId * PageSize
+/// </remarks>
 public class MmfPager : IDisposable
 {
     private readonly String _filePath;
@@ -16,19 +22,13 @@ public class MmfPager : IDisposable
     private readonly Object _lock = new();
     private Boolean _disposed;
 
-    /// <summary>
-    /// 文件路径
-    /// </summary>
+    /// <summary>文件路径</summary>
     public String FilePath => _filePath;
 
-    /// <summary>
-    /// 页大小
-    /// </summary>
+    /// <summary>页大小(字节)</summary>
     public Int32 PageSize => _pageSize;
 
-    /// <summary>
-    /// 页数量
-    /// </summary>
+    /// <summary>当前页数量</summary>
     public UInt64 PageCount
     {
         get
@@ -41,16 +41,25 @@ public class MmfPager : IDisposable
         }
     }
 
+    /// <summary>实例化分页访问器</summary>
+    /// <param name="filePath">数据文件路径</param>
+    /// <param name="pageSize">页大小(字节)</param>
+    /// <param name="enableChecksum">是否启用页校验和</param>
     public MmfPager(String filePath, Int32 pageSize, Boolean enableChecksum = true)
     {
         _filePath = filePath ?? throw new ArgumentNullException(nameof(filePath));
+
+        if (pageSize <= 0)
+            throw new ArgumentOutOfRangeException(nameof(pageSize), "Page size must be positive");
+
         _pageSize = pageSize;
         _enableChecksum = enableChecksum;
     }
 
-    /// <summary>
-    /// 打开或创建文件
-    /// </summary>
+    /// <summary>打开或创建数据文件</summary>
+    /// <param name="header">新文件时写入的文件头(已有文件则忽略)</param>
+    /// <exception cref="ObjectDisposedException">已释放时抛出</exception>
+    /// <exception cref="NovaException">PageSize 与文件不匹配时抛出</exception>
     public void Open(FileHeader? header = null)
     {
         lock (_lock)
@@ -64,23 +73,23 @@ public class MmfPager : IDisposable
 
             if (isNewFile && header != null)
             {
-                // 写入文件头
+                // 新文件写入文件头
                 var headerBytes = header.ToBytes();
                 _fileStream.Write(headerBytes, 0, headerBytes.Length);
                 _fileStream.Flush();
             }
             else if (!isNewFile)
             {
-                // 验证文件头
-                var headerBytes = new Byte[32];
-                _fileStream.Read(headerBytes, 0, 32);
+                // 已有文件验证文件头
+                var headerBytes = new Byte[FileHeader.HeaderSize];
+                var bytesRead = _fileStream.Read(headerBytes, 0, FileHeader.HeaderSize);
+                if (bytesRead < FileHeader.HeaderSize)
+                    throw new NovaException(ErrorCode.FileCorrupted, $"File too short, cannot read header: {_filePath}");
+
                 var fileHeader = FileHeader.FromBytes(headerBytes);
 
                 if (fileHeader.PageSize != _pageSize)
-                {
-                    throw new NovaException(ErrorCode.IncompatibleFileFormat,
-                        $"Page size mismatch: file={fileHeader.PageSize}, expected={_pageSize}");
-                }
+                    throw new NovaException(ErrorCode.IncompatibleFileFormat, $"Page size mismatch: file={fileHeader.PageSize}, expected={_pageSize}");
             }
 
             // 创建内存映射文件
@@ -97,9 +106,13 @@ public class MmfPager : IDisposable
         }
     }
 
-    /// <summary>
-    /// 读取页
-    /// </summary>
+    /// <summary>读取指定页面</summary>
+    /// <param name="pageId">页 ID(从 0 开始)</param>
+    /// <returns>页面数据(长度等于 PageSize)</returns>
+    /// <exception cref="ObjectDisposedException">已释放时抛出</exception>
+    /// <exception cref="InvalidOperationException">未打开时抛出</exception>
+    /// <exception cref="ArgumentOutOfRangeException">页 ID 超出范围时抛出</exception>
+    /// <exception cref="NovaException">校验和不匹配时抛出</exception>
     public Byte[] ReadPage(UInt64 pageId)
     {
         lock (_lock)
@@ -110,48 +123,44 @@ public class MmfPager : IDisposable
             if (_fileStream == null)
                 throw new InvalidOperationException("Pager not opened");
 
-            var offset = 32 + (Int64)(pageId * (UInt64)_pageSize); // 跳过文件头
+            // 偏移 = FileHeader(32B) + PageId * PageSize
+            var offset = FileHeader.HeaderSize + (Int64)(pageId * (UInt64)_pageSize);
             if (offset + _pageSize > _fileStream.Length)
-            {
-                throw new ArgumentOutOfRangeException(nameof(pageId),
-                    $"Page {pageId} is out of range");
-            }
+                throw new ArgumentOutOfRangeException(nameof(pageId), $"Page {pageId} is out of range");
 
-            // 使用 FileStream 直接读取
             _fileStream.Seek(offset, SeekOrigin.Begin);
             var buffer = new Byte[_pageSize];
             var bytesRead = _fileStream.Read(buffer, 0, _pageSize);
             if (bytesRead != _pageSize)
-            {
                 throw new IOException($"Failed to read complete page: expected {_pageSize}, got {bytesRead}");
-            }
 
-            // 验证校验和
+            // 校验和验证
             if (_enableChecksum)
             {
                 var pageHeader = PageHeader.FromBytes(buffer);
-                var computedChecksum = ComputeChecksum(buffer, 32, pageHeader.DataLength);
+                var computedChecksum = ComputeChecksum(buffer, PageHeader.HeaderSize, pageHeader.DataLength);
 
                 if (pageHeader.Checksum != computedChecksum)
-                {
-                    throw new NovaException(ErrorCode.ChecksumFailed,
-                        $"Checksum mismatch for page {pageId}");
-                }
+                    throw new NovaException(ErrorCode.ChecksumFailed, $"Checksum mismatch for page {pageId}");
             }
 
             return buffer;
         }
     }
 
-    /// <summary>
-    /// 写入页
-    /// </summary>
+    /// <summary>写入指定页面</summary>
+    /// <param name="pageId">页 ID(从 0 开始)</param>
+    /// <param name="data">页面数据(长度必须等于 PageSize)</param>
+    /// <exception cref="ArgumentException">数据长度不匹配时抛出</exception>
+    /// <exception cref="ObjectDisposedException">已释放时抛出</exception>
+    /// <exception cref="InvalidOperationException">未打开时抛出</exception>
     public void WritePage(UInt64 pageId, Byte[] data)
     {
-        if (data == null || data.Length != _pageSize)
-        {
-            throw new ArgumentException($"Page data must be exactly {_pageSize} bytes", nameof(data));
-        }
+        if (data == null)
+            throw new ArgumentNullException(nameof(data));
+
+        if (data.Length != _pageSize)
+            throw new ArgumentException($"Page data must be exactly {_pageSize} bytes, got {data.Length}", nameof(data));
 
         lock (_lock)
         {
@@ -161,35 +170,30 @@ public class MmfPager : IDisposable
             if (_fileStream == null)
                 throw new InvalidOperationException("Pager not opened");
 
-            var offset = 32 + (Int64)(pageId * (UInt64)_pageSize);
+            var offset = FileHeader.HeaderSize + (Int64)(pageId * (UInt64)_pageSize);
 
             // 计算并设置校验和
             if (_enableChecksum)
             {
                 var pageHeader = PageHeader.FromBytes(data);
-                var checksum = ComputeChecksum(data, 32, pageHeader.DataLength);
+                var checksum = ComputeChecksum(data, PageHeader.HeaderSize, pageHeader.DataLength);
                 pageHeader.Checksum = checksum;
 
                 var headerBytes = pageHeader.ToBytes();
-                Buffer.BlockCopy(headerBytes, 0, data, 0, 32);
+                Buffer.BlockCopy(headerBytes, 0, data, 0, PageHeader.HeaderSize);
             }
 
-            // 扩展文件如果需要
+            // 按需扩展文件
             if (offset + _pageSize > _fileStream.Length)
-            {
                 _fileStream.SetLength(offset + _pageSize);
-            }
 
-            // 直接写入文件流(不使用MMF写入以避免重新映射问题)
             _fileStream.Seek(offset, SeekOrigin.Begin);
             _fileStream.Write(data, 0, _pageSize);
             _fileStream.Flush();
         }
     }
 
-    /// <summary>
-    /// 刷新到磁盘
-    /// </summary>
+    /// <summary>刷新缓冲区到磁盘</summary>
     public void Flush()
     {
         lock (_lock)
@@ -198,9 +202,11 @@ public class MmfPager : IDisposable
         }
     }
 
-    /// <summary>
-    /// 计算校验和(CRC32 简化版)
-    /// </summary>
+    /// <summary>计算校验和(CRC32 简化版)</summary>
+    /// <param name="buffer">数据缓冲区</param>
+    /// <param name="offset">起始偏移</param>
+    /// <param name="length">数据长度</param>
+    /// <returns>校验和值</returns>
     private UInt32 ComputeChecksum(Byte[] buffer, Int32 offset, UInt32 length)
     {
         UInt32 checksum = 0;
@@ -211,6 +217,7 @@ public class MmfPager : IDisposable
         return checksum;
     }
 
+    /// <summary>释放资源</summary>
     public void Dispose()
     {
         lock (_lock)
Modified +41 -26
diff --git a/NewLife.NovaDb/Storage/PageCache.cs b/NewLife.NovaDb/Storage/PageCache.cs
index 040b572..9ce29ad 100644
--- a/NewLife.NovaDb/Storage/PageCache.cs
+++ b/NewLife.NovaDb/Storage/PageCache.cs
@@ -1,8 +1,12 @@
 namespace NewLife.NovaDb.Storage;
 
-/// <summary>
-/// 页缓存(LRU 策略)
-/// </summary>
+/// <summary>页缓存(LRU 策略)</summary>
+/// <remarks>
+/// 基于 LinkedList + Dictionary 实现 O(1) 的 LRU 缓存:
+/// - TryGet: 命中时将页移至链表头部(最近访问)
+/// - Put: 容量满时淘汰链表尾部(最久未访问)
+/// - 线程安全:所有操作均在锁保护下执行
+/// </remarks>
 public class PageCache
 {
     private readonly Int32 _capacity;
@@ -10,14 +14,10 @@ public class PageCache
     private readonly LinkedList<UInt64> _lruList;
     private readonly Object _lock = new();
 
-    /// <summary>
-    /// 缓存大小(页数)
-    /// </summary>
+    /// <summary>缓存容量(最大页数)</summary>
     public Int32 Capacity => _capacity;
 
-    /// <summary>
-    /// 当前缓存页数
-    /// </summary>
+    /// <summary>当前缓存页数</summary>
     public Int32 Count
     {
         get
@@ -29,6 +29,15 @@ public class PageCache
         }
     }
 
+    /// <summary>命中次数</summary>
+    public Int64 HitCount { get; private set; }
+
+    /// <summary>未命中次数</summary>
+    public Int64 MissCount { get; private set; }
+
+    /// <summary>实例化页缓存</summary>
+    /// <param name="capacity">最大缓存页数(必须为正整数)</param>
+    /// <exception cref="ArgumentOutOfRangeException">capacity 不为正整数时抛出</exception>
     public PageCache(Int32 capacity)
     {
         if (capacity <= 0)
@@ -39,31 +48,35 @@ public class PageCache
         _lruList = new LinkedList<UInt64>();
     }
 
-    /// <summary>
-    /// 尝试获取缓存的页
-    /// </summary>
+    /// <summary>尝试获取缓存的页</summary>
+    /// <param name="pageId">页 ID</param>
+    /// <param name="data">命中时返回页数据,未命中返回 null</param>
+    /// <returns>是否命中缓存</returns>
     public Boolean TryGet(UInt64 pageId, out Byte[]? data)
     {
         lock (_lock)
         {
             if (_cache.TryGetValue(pageId, out var entry))
             {
-                // 移动到 LRU 列表头部
+                // 移动到 LRU 链表头部(最近访问)
                 _lruList.Remove(entry.LruNode);
                 entry.LruNode = _lruList.AddFirst(pageId);
 
                 data = entry.Data;
+                HitCount++;
                 return true;
             }
 
             data = null;
+            MissCount++;
             return false;
         }
     }
 
-    /// <summary>
-    /// 添加页到缓存
-    /// </summary>
+    /// <summary>添加或更新页到缓存</summary>
+    /// <param name="pageId">页 ID</param>
+    /// <param name="data">页数据</param>
+    /// <exception cref="ArgumentNullException">data 为 null 时抛出</exception>
     public void Put(UInt64 pageId, Byte[] data)
     {
         if (data == null)
@@ -71,7 +84,7 @@ public class PageCache
 
         lock (_lock)
         {
-            // 如果已存在,更新数据并移到头部
+            // 已存在则更新数据并移到头部
             if (_cache.TryGetValue(pageId, out var entry))
             {
                 entry.Data = data;
@@ -80,7 +93,7 @@ public class PageCache
                 return;
             }
 
-            // 如果达到容量,淘汰最久未使用的页
+            // 容量满时淘汰最久未使用的页
             if (_cache.Count >= _capacity)
             {
                 var lruPageId = _lruList.Last!.Value;
@@ -88,7 +101,7 @@ public class PageCache
                 _cache.Remove(lruPageId);
             }
 
-            // 添加新页
+            // 添加新页到头部
             var node = _lruList.AddFirst(pageId);
             _cache[pageId] = new CacheEntry
             {
@@ -98,10 +111,10 @@ public class PageCache
         }
     }
 
-    /// <summary>
-    /// 移除页从缓存
-    /// </summary>
-    public void Remove(UInt64 pageId)
+    /// <summary>从缓存中移除指定页</summary>
+    /// <param name="pageId">页 ID</param>
+    /// <returns>是否成功移除(页不存在时返回 false)</returns>
+    public Boolean Remove(UInt64 pageId)
     {
         lock (_lock)
         {
@@ -109,19 +122,21 @@ public class PageCache
             {
                 _lruList.Remove(entry.LruNode);
                 _cache.Remove(pageId);
+                return true;
             }
+            return false;
         }
     }
 
-    /// <summary>
-    /// 清空缓存
-    /// </summary>
+    /// <summary>清空全部缓存</summary>
     public void Clear()
     {
         lock (_lock)
         {
             _cache.Clear();
             _lruList.Clear();
+            HitCount = 0;
+            MissCount = 0;
         }
     }
 
Modified +40 -33
diff --git a/NewLife.NovaDb/Storage/PageHeader.cs b/NewLife.NovaDb/Storage/PageHeader.cs
index b616a82..dd34d40 100644
--- a/NewLife.NovaDb/Storage/PageHeader.cs
+++ b/NewLife.NovaDb/Storage/PageHeader.cs
@@ -1,41 +1,41 @@
 namespace NewLife.NovaDb.Storage;
 
-/// <summary>
-/// 页头结构(每个页的开头)
-/// </summary>
+/// <summary>页头结构(每个页的开头,固定 32 字节)</summary>
+/// <remarks>
+/// 页头布局(32 字节):
+/// - 0-7: PageId (页 ID)
+/// - 8: PageType (0=Empty, 1=Data, 2=Index, 3=Directory, 4=Metadata)
+/// - 9-11: Reserved (预留)
+/// - 12-19: LSN (日志序列号)
+/// - 20-23: Checksum (CRC32 校验和)
+/// - 24-27: DataLength (页内有效数据长度)
+/// - 28-31: Reserved (预留扩展)
+/// </remarks>
 public class PageHeader
 {
-    /// <summary>
-    /// 页 ID
-    /// </summary>
+    /// <summary>页头固定大小(32 字节)</summary>
+    public const Int32 HeaderSize = 32;
+
+    /// <summary>页 ID</summary>
     public UInt64 PageId { get; set; }
 
-    /// <summary>
-    /// 页类型
-    /// </summary>
+    /// <summary>页类型</summary>
     public PageType PageType { get; set; }
 
-    /// <summary>
-    /// 日志序列号(LSN)
-    /// </summary>
+    /// <summary>日志序列号(LSN)</summary>
     public UInt64 Lsn { get; set; }
 
-    /// <summary>
-    /// 校验和(CRC32)
-    /// </summary>
+    /// <summary>校验和(CRC32)</summary>
     public UInt32 Checksum { get; set; }
 
-    /// <summary>
-    /// 页内有效数据长度
-    /// </summary>
+    /// <summary>页内有效数据长度</summary>
     public UInt32 DataLength { get; set; }
 
-    /// <summary>
-    /// 序列化为字节数组(固定 32 字节)
-    /// </summary>
+    /// <summary>序列化为字节数组(固定 32 字节)</summary>
+    /// <returns>32 字节的页头数据</returns>
     public Byte[] ToBytes()
     {
-        var buffer = new Byte[32];
+        var buffer = new Byte[HeaderSize];
         var offset = 0;
 
         // PageId (8 bytes)
@@ -60,21 +60,24 @@ public class PageHeader
         Buffer.BlockCopy(BitConverter.GetBytes(DataLength), 0, buffer, offset, 4);
         offset += 4;
 
-        // Reserved (4 bytes)
-        // offset += 4;
+        // Reserved (4 bytes) - 自动为 0
 
         return buffer;
     }
 
-    /// <summary>
-    /// 从字节数组反序列化
-    /// </summary>
+    /// <summary>从字节数组反序列化</summary>
+    /// <param name="buffer">包含页头数据的字节数组(至少 32 字节)</param>
+    /// <returns>反序列化的页头对象</returns>
+    /// <exception cref="ArgumentNullException">buffer 为 null</exception>
+    /// <exception cref="ArgumentException">buffer 长度不足 32 字节</exception>
+    /// <exception cref="Core.NovaException">页类型无效</exception>
     public static PageHeader FromBytes(Byte[] buffer)
     {
-        if (buffer.Length < 32)
-        {
-            throw new ArgumentException("Buffer too short for PageHeader");
-        }
+        if (buffer == null)
+            throw new ArgumentNullException(nameof(buffer));
+
+        if (buffer.Length < HeaderSize)
+            throw new ArgumentException($"Buffer too short for PageHeader, expected {HeaderSize} bytes, got {buffer.Length}", nameof(buffer));
 
         var offset = 0;
 
@@ -82,8 +85,12 @@ public class PageHeader
         var pageId = BitConverter.ToUInt64(buffer, offset);
         offset += 8;
 
-        // PageType
-        var pageType = (PageType)buffer[offset++];
+        // PageType 验证
+        var pageTypeByte = buffer[offset++];
+        if (!Enum.IsDefined(typeof(PageType), pageTypeByte))
+            throw new Core.NovaException(Core.ErrorCode.FileCorrupted, $"Invalid page type: {pageTypeByte}");
+
+        var pageType = (PageType)pageTypeByte;
 
         // Reserved
         offset += 3;
Deleted +0 -133
NewLife.NovaDb/Storage/TableDirectory.cs
Added +201 -0
diff --git a/NewLife.NovaDb/Storage/TableFileManager.cs b/NewLife.NovaDb/Storage/TableFileManager.cs
new file mode 100644
index 0000000..c47746d
--- /dev/null
+++ b/NewLife.NovaDb/Storage/TableFileManager.cs
@@ -0,0 +1,201 @@
+using NewLife.NovaDb.Core;
+
+namespace NewLife.NovaDb.Storage;
+
+/// <summary>表文件管理器(文件路径生成与命名规则)</summary>
+/// <remarks>
+/// 负责生成表相关文件的路径,基于新的平铺文件布局:
+/// - 数据文件:{TableName}.data 或 {TableName}_{ShardId}.data
+/// - 索引文件:{TableName}.idx(主键)或 {TableName}_{IndexName}.idx(二级索引)
+/// - WAL 文件:{TableName}.wal 或 {TableName}_{ShardId}.wal
+/// 所有文件平铺在数据库目录下,无需为每表创建独立目录
+/// </remarks>
+public class TableFileManager
+{
+    private readonly String _databasePath;
+    private readonly String _tableName;
+    private readonly DbOptions _options;
+
+    /// <summary>数据库路径</summary>
+    public String DatabasePath => _databasePath;
+
+    /// <summary>表名</summary>
+    public String TableName => _tableName;
+
+    /// <summary>构造表文件管理器</summary>
+    /// <param name="databasePath">数据库目录路径</param>
+    /// <param name="tableName">表名</param>
+    /// <param name="options">数据库配置</param>
+    public TableFileManager(String databasePath, String tableName, DbOptions options)
+    {
+        _databasePath = databasePath ?? throw new ArgumentNullException(nameof(databasePath));
+        _tableName = tableName ?? throw new ArgumentNullException(nameof(tableName));
+        _options = options ?? throw new ArgumentNullException(nameof(options));
+
+        if (String.IsNullOrWhiteSpace(_tableName))
+            throw new ArgumentException("Table name cannot be empty", nameof(tableName));
+    }
+
+    /// <summary>获取数据文件路径</summary>
+    /// <param name="shardId">分片 ID(可选,默认 null 表示无分片)</param>
+    /// <returns>数据文件完整路径</returns>
+    public String GetDataFilePath(Int32? shardId = null)
+    {
+        var fileName = shardId.HasValue
+            ? $"{_tableName}_{shardId.Value}.data"
+            : $"{_tableName}.data";
+
+        return System.IO.Path.Combine(_databasePath, fileName);
+    }
+
+    /// <summary>获取主键索引文件路径</summary>
+    /// <param name="shardId">分片 ID(可选,默认 null 表示无分片)</param>
+    /// <returns>主键索引文件完整路径</returns>
+    public String GetPrimaryIndexFilePath(Int32? shardId = null)
+    {
+        var fileName = shardId.HasValue
+            ? $"{_tableName}_{shardId.Value}.idx"
+            : $"{_tableName}.idx";
+
+        return System.IO.Path.Combine(_databasePath, fileName);
+    }
+
+    /// <summary>获取二级索引文件路径</summary>
+    /// <param name="indexName">索引名称</param>
+    /// <param name="shardId">分片 ID(可选,默认 null 表示无分片)</param>
+    /// <returns>二级索引文件完整路径</returns>
+    public String GetSecondaryIndexFilePath(String indexName, Int32? shardId = null)
+    {
+        if (String.IsNullOrWhiteSpace(indexName))
+            throw new ArgumentException("Index name cannot be empty", nameof(indexName));
+
+        var fileName = shardId.HasValue
+            ? $"{_tableName}_{shardId.Value}_{indexName}.idx"
+            : $"{_tableName}_{indexName}.idx";
+
+        return System.IO.Path.Combine(_databasePath, fileName);
+    }
+
+    /// <summary>获取 WAL 文件路径</summary>
+    /// <param name="shardId">分片 ID(可选,默认 null 表示无分片)</param>
+    /// <returns>WAL 文件完整路径</returns>
+    public String GetWalFilePath(Int32? shardId = null)
+    {
+        var fileName = shardId.HasValue
+            ? $"{_tableName}_{shardId.Value}.wal"
+            : $"{_tableName}.wal";
+
+        return System.IO.Path.Combine(_databasePath, fileName);
+    }
+
+    /// <summary>列举所有数据分片 ID</summary>
+    /// <returns>分片 ID 列表,按从小到大排序</returns>
+    public IEnumerable<Int32> ListDataShards()
+    {
+        if (!Directory.Exists(_databasePath))
+            return Enumerable.Empty<Int32>();
+
+        var pattern = $"{_tableName}_*.data";
+        var files = Directory.GetFiles(_databasePath, pattern);
+
+        var shardIds = new List<Int32>();
+        foreach (var file in files)
+        {
+            var fileName = System.IO.Path.GetFileNameWithoutExtension(file);
+            // fileName 格式:{TableName}_{ShardId}
+            var parts = fileName.Split('_');
+            if (parts.Length >= 2 && Int32.TryParse(parts[^1], out var shardId))
+            {
+                shardIds.Add(shardId);
+            }
+        }
+
+        return shardIds.OrderBy(x => x);
+    }
+
+    /// <summary>列举所有二级索引名称</summary>
+    /// <returns>索引名称列表</returns>
+    public IEnumerable<String> ListSecondaryIndexes()
+    {
+        if (!Directory.Exists(_databasePath))
+            return Enumerable.Empty<String>();
+
+        var pattern = $"{_tableName}_*.idx";
+        var files = Directory.GetFiles(_databasePath, pattern);
+
+        var indexes = new HashSet<String>();
+        foreach (var file in files)
+        {
+            var fileName = System.IO.Path.GetFileNameWithoutExtension(file);
+            // fileName 格式:{TableName}_{IndexName} 或 {TableName}_{ShardId}_{IndexName}
+
+            // 移除表名前缀
+            var suffix = fileName.Substring(_tableName.Length + 1); // +1 for '_'
+            var parts = suffix.Split('_');
+
+            if (parts.Length >= 1)
+            {
+                // 检查第一部分是否为数字(分片 ID)
+                if (Int32.TryParse(parts[0], out _))
+                {
+                    // {TableName}_{ShardId}_{IndexName} - 提取索引名(剩余部分)
+                    if (parts.Length >= 2)
+                    {
+                        var indexName = String.Join("_", parts.Skip(1));
+                        if (!String.IsNullOrEmpty(indexName))
+                        {
+                            indexes.Add(indexName);
+                        }
+                    }
+                }
+                else
+                {
+                    // {TableName}_{IndexName} - 整个 suffix 就是索引名
+                    indexes.Add(suffix);
+                }
+            }
+        }
+
+        return indexes.OrderBy(x => x);
+    }
+
+    /// <summary>删除表的所有文件</summary>
+    /// <remarks>删除数据文件、索引文件、WAL 文件</remarks>
+    public void DeleteAllFiles()
+    {
+        if (!Directory.Exists(_databasePath))
+            return;
+
+        // 删除数据文件(包括分片)
+        var dataPattern = $"{_tableName}*.data";
+        foreach (var file in Directory.GetFiles(_databasePath, dataPattern))
+        {
+            File.Delete(file);
+        }
+
+        // 删除索引文件(主键和二级索引)
+        var indexPattern = $"{_tableName}*.idx";
+        foreach (var file in Directory.GetFiles(_databasePath, indexPattern))
+        {
+            File.Delete(file);
+        }
+
+        // 删除 WAL 文件
+        var walPattern = $"{_tableName}*.wal";
+        foreach (var file in Directory.GetFiles(_databasePath, walPattern))
+        {
+            File.Delete(file);
+        }
+    }
+
+    /// <summary>检查表文件是否存在</summary>
+    /// <returns>如果至少存在一个表相关文件则返回 true</returns>
+    public Boolean Exists()
+    {
+        if (!Directory.Exists(_databasePath))
+            return false;
+
+        var dataFile = GetDataFilePath();
+        return File.Exists(dataFile);
+    }
+}
Added +0 -0
diff --git a/temp.txt b/temp.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/temp.txt
Modified +149 -0
diff --git a/XUnitTest/Core/DataCodecTests.cs b/XUnitTest/Core/DataCodecTests.cs
index 7ab7f5a..5a3d6c2 100644
--- a/XUnitTest/Core/DataCodecTests.cs
+++ b/XUnitTest/Core/DataCodecTests.cs
@@ -110,4 +110,153 @@ public class DataCodecTests
         Assert.Equal(8, _codec.GetEncodedLength(DateTime.Now, DataType.DateTime));
         Assert.Equal(4, _codec.GetEncodedLength(null, DataType.String));
     }
+
+    [Fact]
+    public void TestEncodeDecodeEmptyString()
+    {
+        var value = "";
+        var encoded = _codec.Encode(value, DataType.String);
+        var decoded = _codec.Decode(encoded, 0, DataType.String);
+
+        Assert.Equal(value, decoded);
+        Assert.Equal(4, encoded.Length); // 长度前缀 4 字节 + 0 字节数据
+    }
+
+    [Fact]
+    public void TestEncodeDecodeEmptyByteArray()
+    {
+        var value = Array.Empty<Byte>();
+        var encoded = _codec.Encode(value, DataType.Binary);
+        var decoded = (Byte[])_codec.Decode(encoded, 0, DataType.Binary)!;
+
+        Assert.Equal(value, decoded);
+        Assert.Equal(4, encoded.Length); // 长度前缀 4 字节 + 0 字节数据
+    }
+
+    [Fact]
+    public void TestEncodeDecodeUnicodeString()
+    {
+        var value = "你好,NovaDb!🚀";
+        var encoded = _codec.Encode(value, DataType.String);
+        var decoded = _codec.Decode(encoded, 0, DataType.String);
+
+        Assert.Equal(value, decoded);
+    }
+
+    [Fact]
+    public void TestEncodeDecodeMinMaxInt32()
+    {
+        var minEncoded = _codec.Encode(Int32.MinValue, DataType.Int32);
+        var minDecoded = _codec.Decode(minEncoded, 0, DataType.Int32);
+        Assert.Equal(Int32.MinValue, minDecoded);
+
+        var maxEncoded = _codec.Encode(Int32.MaxValue, DataType.Int32);
+        var maxDecoded = _codec.Decode(maxEncoded, 0, DataType.Int32);
+        Assert.Equal(Int32.MaxValue, maxDecoded);
+    }
+
+    [Fact]
+    public void TestEncodeDecodeMinMaxInt64()
+    {
+        var minEncoded = _codec.Encode(Int64.MinValue, DataType.Int64);
+        var minDecoded = _codec.Decode(minEncoded, 0, DataType.Int64);
+        Assert.Equal(Int64.MinValue, minDecoded);
+
+        var maxEncoded = _codec.Encode(Int64.MaxValue, DataType.Int64);
+        var maxDecoded = _codec.Decode(maxEncoded, 0, DataType.Int64);
+        Assert.Equal(Int64.MaxValue, maxDecoded);
+    }
+
+    [Fact]
+    public void TestEncodeDecodeMinMaxDouble()
+    {
+        var minEncoded = _codec.Encode(Double.MinValue, DataType.Double);
+        var minDecoded = _codec.Decode(minEncoded, 0, DataType.Double);
+        Assert.Equal(Double.MinValue, minDecoded);
+
+        var maxEncoded = _codec.Encode(Double.MaxValue, DataType.Double);
+        var maxDecoded = _codec.Decode(maxEncoded, 0, DataType.Double);
+        Assert.Equal(Double.MaxValue, maxDecoded);
+    }
+
+    [Fact]
+    public void TestEncodeDecodeMinMaxDecimal()
+    {
+        var minEncoded = _codec.Encode(Decimal.MinValue, DataType.Decimal);
+        var minDecoded = _codec.Decode(minEncoded, 0, DataType.Decimal);
+        Assert.Equal(Decimal.MinValue, minDecoded);
+
+        var maxEncoded = _codec.Encode(Decimal.MaxValue, DataType.Decimal);
+        var maxDecoded = _codec.Decode(maxEncoded, 0, DataType.Decimal);
+        Assert.Equal(Decimal.MaxValue, maxDecoded);
+    }
+
+    [Fact]
+    public void TestEncodeDecodeMinMaxDateTime()
+    {
+        var minEncoded = _codec.Encode(DateTime.MinValue, DataType.DateTime);
+        var minDecoded = _codec.Decode(minEncoded, 0, DataType.DateTime);
+        Assert.Equal(DateTime.MinValue, minDecoded);
+
+        var maxEncoded = _codec.Encode(DateTime.MaxValue, DataType.DateTime);
+        var maxDecoded = _codec.Decode(maxEncoded, 0, DataType.DateTime);
+        Assert.Equal(DateTime.MaxValue, maxDecoded);
+    }
+
+    [Fact]
+    public void TestDecodeWithOffset()
+    {
+        // 构造一个包含多个值的缓冲区
+        var value1 = 12345;
+        var value2 = 67890;
+        var encoded1 = _codec.Encode(value1, DataType.Int32);
+        var encoded2 = _codec.Encode(value2, DataType.Int32);
+
+        var buffer = new Byte[encoded1.Length + encoded2.Length];
+        Buffer.BlockCopy(encoded1, 0, buffer, 0, encoded1.Length);
+        Buffer.BlockCopy(encoded2, 0, buffer, encoded1.Length, encoded2.Length);
+
+        // 从不同偏移解码
+        var decoded1 = _codec.Decode(buffer, 0, DataType.Int32);
+        var decoded2 = _codec.Decode(buffer, encoded1.Length, DataType.Int32);
+
+        Assert.Equal(value1, decoded1);
+        Assert.Equal(value2, decoded2);
+    }
+
+    [Fact]
+    public void TestEncodeDecodeNullForAllTypes()
+    {
+        // 测试所有支持 NULL 的类型
+        var types = new[] { DataType.String, DataType.Binary };
+        foreach (var type in types)
+        {
+            var encoded = _codec.Encode(null, type);
+            var decoded = _codec.Decode(encoded, 0, type);
+            Assert.Null(decoded);
+        }
+    }
+
+    [Fact]
+    public void TestEncodeUnsupportedType()
+    {
+        // 测试不支持的类型
+        var ex = Assert.Throws<NotSupportedException>(() => _codec.Encode(123, (DataType)255));
+        Assert.Contains("Unsupported data type", ex.Message);
+    }
+
+    [Fact]
+    public void TestDecodeUnsupportedType()
+    {
+        var buffer = new Byte[4];
+        var ex = Assert.Throws<NotSupportedException>(() => _codec.Decode(buffer, 0, (DataType)255));
+        Assert.Contains("Unsupported data type", ex.Message);
+    }
+
+    [Fact]
+    public void TestGetEncodedLengthUnsupportedType()
+    {
+        var ex = Assert.Throws<NotSupportedException>(() => _codec.GetEncodedLength(123, (DataType)255));
+        Assert.Contains("Unsupported data type", ex.Message);
+    }
 }
Added +147 -0
diff --git a/XUnitTest/Core/DataTypeExtensionsTests.cs b/XUnitTest/Core/DataTypeExtensionsTests.cs
new file mode 100644
index 0000000..1bd5262
--- /dev/null
+++ b/XUnitTest/Core/DataTypeExtensionsTests.cs
@@ -0,0 +1,147 @@
+using System;
+using NewLife.NovaDb.Core;
+using Xunit;
+
+namespace XUnitTest.Core;
+
+/// <summary>DataType 扩展方法测试</summary>
+public class DataTypeExtensionsTests
+{
+    [Theory]
+    [InlineData(DataType.Boolean, typeof(Boolean))]
+    [InlineData(DataType.Int32, typeof(Int32))]
+    [InlineData(DataType.Int64, typeof(Int64))]
+    [InlineData(DataType.Double, typeof(Double))]
+    [InlineData(DataType.Decimal, typeof(Decimal))]
+    [InlineData(DataType.DateTime, typeof(DateTime))]
+    [InlineData(DataType.String, typeof(String))]
+    [InlineData(DataType.Binary, typeof(Byte[]))]
+    [InlineData(DataType.GeoPoint, typeof(GeoPoint))]
+    [InlineData(DataType.Vector, typeof(Single[]))]
+    public void TestGetClrType(DataType dataType, Type expectedType)
+    {
+        var clrType = dataType.GetClrType();
+        Assert.Equal(expectedType, clrType);
+    }
+
+    [Theory]
+    [InlineData(typeof(Boolean), DataType.Boolean)]
+    [InlineData(typeof(Int32), DataType.Int32)]
+    [InlineData(typeof(Int64), DataType.Int64)]
+    [InlineData(typeof(Double), DataType.Double)]
+    [InlineData(typeof(Decimal), DataType.Decimal)]
+    [InlineData(typeof(DateTime), DataType.DateTime)]
+    [InlineData(typeof(String), DataType.String)]
+    [InlineData(typeof(Byte[]), DataType.Binary)]
+    [InlineData(typeof(GeoPoint), DataType.GeoPoint)]
+    [InlineData(typeof(Single[]), DataType.Vector)]
+    public void TestFromClrType(Type clrType, DataType expectedDataType)
+    {
+        var dataType = DataTypeExtensions.FromClrType(clrType);
+        Assert.Equal(expectedDataType, dataType);
+    }
+
+    [Fact]
+    public void TestRoundTripConversion()
+    {
+        // 测试所有支持的类型往返转换
+        var allDataTypes = new[]
+        {
+            DataType.Boolean, DataType.Int32, DataType.Int64, DataType.Double,
+            DataType.Decimal, DataType.DateTime, DataType.String, DataType.Binary,
+            DataType.GeoPoint, DataType.Vector
+        };
+
+        foreach (var dataType in allDataTypes)
+        {
+            var clrType = dataType.GetClrType();
+            var backToDataType = DataTypeExtensions.FromClrType(clrType);
+            Assert.Equal(dataType, backToDataType);
+        }
+    }
+
+    [Fact]
+    public void TestGetClrTypeUnsupported()
+    {
+        var ex = Assert.Throws<NotSupportedException>(() => ((DataType)255).GetClrType());
+        Assert.Contains("Unsupported data type", ex.Message);
+    }
+
+    [Fact]
+    public void TestFromClrTypeUnsupported()
+    {
+        var ex = Assert.Throws<NotSupportedException>(() => DataTypeExtensions.FromClrType(typeof(Object)));
+        Assert.Contains("Unsupported CLR type", ex.Message);
+    }
+
+    [Fact]
+    public void TestDataTypeEnumValues()
+    {
+        // 验证基础类型的枚举值与 TypeCode 保持一致
+        Assert.Equal(3, (Byte)DataType.Boolean);
+        Assert.Equal(9, (Byte)DataType.Int32);
+        Assert.Equal(11, (Byte)DataType.Int64);
+        Assert.Equal(14, (Byte)DataType.Double);
+        Assert.Equal(15, (Byte)DataType.Decimal);
+        Assert.Equal(16, (Byte)DataType.DateTime);
+        Assert.Equal(18, (Byte)DataType.String);
+
+        // 自定义类型
+        Assert.Equal(101, (Byte)DataType.Binary);
+        Assert.Equal(102, (Byte)DataType.GeoPoint);
+        Assert.Equal(103, (Byte)DataType.Vector);
+    }
+}
+
+/// <summary>GeoPoint 结构体测试</summary>
+public class GeoPointTests
+{
+    [Fact]
+    public void TestConstructor()
+    {
+        var point = new GeoPoint(39.9042, 116.4074);
+
+        Assert.Equal(39.9042, point.Latitude);
+        Assert.Equal(116.4074, point.Longitude);
+    }
+
+    [Fact]
+    public void TestToString()
+    {
+        var point = new GeoPoint(39.9042, 116.4074);
+        var str = point.ToString();
+
+        Assert.Contains("39.9042", str);
+        Assert.Contains("116.4074", str);
+    }
+
+    [Fact]
+    public void TestPropertySetter()
+    {
+        var point = new GeoPoint(0, 0)
+        {
+            Latitude = 31.2304,
+            Longitude = 121.4737
+        };
+
+        Assert.Equal(31.2304, point.Latitude);
+        Assert.Equal(121.4737, point.Longitude);
+    }
+
+    [Fact]
+    public void TestBoundaryValues()
+    {
+        // 测试边界值
+        var northPole = new GeoPoint(90, 0);
+        Assert.Equal(90, northPole.Latitude);
+
+        var southPole = new GeoPoint(-90, 0);
+        Assert.Equal(-90, southPole.Latitude);
+
+        var dateLine = new GeoPoint(0, 180);
+        Assert.Equal(180, dateLine.Longitude);
+
+        var antiMeridian = new GeoPoint(0, -180);
+        Assert.Equal(-180, antiMeridian.Longitude);
+    }
+}
Added +150 -0
diff --git a/XUnitTest/Core/DbOptionsTests.cs b/XUnitTest/Core/DbOptionsTests.cs
new file mode 100644
index 0000000..ca898ed
--- /dev/null
+++ b/XUnitTest/Core/DbOptionsTests.cs
@@ -0,0 +1,150 @@
+using System;
+using NewLife.NovaDb.Core;
+using Xunit;
+
+namespace XUnitTest.Core;
+
+/// <summary>DbOptions 配置选项测试</summary>
+public class DbOptionsTests
+{
+    [Fact]
+    public void TestDefaultValues()
+    {
+        var options = new DbOptions();
+
+        Assert.Equal(String.Empty, options.Path);
+        Assert.Equal(WalMode.Normal, options.WalMode);
+        Assert.Equal(4096, options.PageSize);
+        Assert.Equal(600, options.HotWindowSeconds);
+        Assert.Equal(1800, options.ColdEvictionSeconds);
+        Assert.Equal(1024L * 1024 * 1024, options.ShardSizeThreshold);
+        Assert.Equal(10_000_000, options.ShardRowThreshold);
+        Assert.True(options.EnableChecksum);
+        Assert.Equal(1024, options.PageCacheSize);
+        Assert.Equal(1, options.FluxPartitionHours);
+        Assert.Equal(0, options.FluxDefaultTtlSeconds);
+    }
+
+    [Fact]
+    public void TestSetPath()
+    {
+        var options = new DbOptions
+        {
+            Path = "/data/nova"
+        };
+
+        Assert.Equal("/data/nova", options.Path);
+    }
+
+    [Fact]
+    public void TestSetWalMode()
+    {
+        var options = new DbOptions
+        {
+            WalMode = WalMode.Full
+        };
+
+        Assert.Equal(WalMode.Full, options.WalMode);
+    }
+
+    [Fact]
+    public void TestSetPageSize()
+    {
+        var options = new DbOptions
+        {
+            PageSize = 8192
+        };
+
+        Assert.Equal(8192, options.PageSize);
+    }
+
+    [Fact]
+    public void TestSetHotWindowSeconds()
+    {
+        var options = new DbOptions
+        {
+            HotWindowSeconds = 1200
+        };
+
+        Assert.Equal(1200, options.HotWindowSeconds);
+    }
+
+    [Fact]
+    public void TestSetColdEvictionSeconds()
+    {
+        var options = new DbOptions
+        {
+            ColdEvictionSeconds = 3600
+        };
+
+        Assert.Equal(3600, options.ColdEvictionSeconds);
+    }
+
+    [Fact]
+    public void TestSetShardThresholds()
+    {
+        var options = new DbOptions
+        {
+            ShardSizeThreshold = 2L * 1024 * 1024 * 1024,
+            ShardRowThreshold = 20_000_000
+        };
+
+        Assert.Equal(2L * 1024 * 1024 * 1024, options.ShardSizeThreshold);
+        Assert.Equal(20_000_000, options.ShardRowThreshold);
+    }
+
+    [Fact]
+    public void TestSetEnableChecksum()
+    {
+        var options = new DbOptions
+        {
+            EnableChecksum = false
+        };
+
+        Assert.False(options.EnableChecksum);
+    }
+
+    [Fact]
+    public void TestSetPageCacheSize()
+    {
+        var options = new DbOptions
+        {
+            PageCacheSize = 2048
+        };
+
+        Assert.Equal(2048, options.PageCacheSize);
+    }
+
+    [Fact]
+    public void TestSetFluxOptions()
+    {
+        var options = new DbOptions
+        {
+            FluxPartitionHours = 24,
+            FluxDefaultTtlSeconds = 86400
+        };
+
+        Assert.Equal(24, options.FluxPartitionHours);
+        Assert.Equal(86400, options.FluxDefaultTtlSeconds);
+    }
+}
+
+/// <summary>WalMode 枚举测试</summary>
+public class WalModeTests
+{
+    [Fact]
+    public void TestWalModeValues()
+    {
+        Assert.Equal(0, (Int32)WalMode.None);
+        Assert.Equal(1, (Int32)WalMode.Normal);
+        Assert.Equal(2, (Int32)WalMode.Full);
+    }
+
+    [Fact]
+    public void TestWalModeName()
+    {
+        Assert.Equal("None", WalMode.None.ToString());
+        Assert.Equal("Normal", WalMode.Normal.ToString());
+        Assert.Equal("Full", WalMode.Full.ToString());
+    }
+}
Added +150 -0
diff --git a/XUnitTest/Core/NovaExceptionTests.cs b/XUnitTest/Core/NovaExceptionTests.cs
new file mode 100644
index 0000000..229e549
--- /dev/null
+++ b/XUnitTest/Core/NovaExceptionTests.cs
@@ -0,0 +1,150 @@
+using System;
+using NewLife.NovaDb.Core;
+using Xunit;
+
+namespace XUnitTest.Core;
+
+/// <summary>NovaException 异常测试</summary>
+public class NovaExceptionTests
+{
+    [Fact]
+    public void TestConstructorWithMessage()
+    {
+        var exception = new NovaException(ErrorCode.TableNotFound, "Table 'users' not found");
+
+        Assert.Equal(ErrorCode.TableNotFound, exception.Code);
+        Assert.Equal("Table 'users' not found", exception.Message);
+        Assert.Null(exception.InnerException);
+    }
+
+    [Fact]
+    public void TestConstructorWithInnerException()
+    {
+        var innerException = new InvalidOperationException("Inner error");
+        var exception = new NovaException(
+            ErrorCode.IoError,
+            "Failed to read file",
+            innerException
+        );
+
+        Assert.Equal(ErrorCode.IoError, exception.Code);
+        Assert.Equal("Failed to read file", exception.Message);
+        Assert.Same(innerException, exception.InnerException);
+    }
+
+    [Fact]
+    public void TestErrorCodePreservedThroughCatch()
+    {
+        try
+        {
+            throw new NovaException(ErrorCode.ChecksumFailed, "Checksum mismatch");
+        }
+        catch (NovaException ex)
+        {
+            Assert.Equal(ErrorCode.ChecksumFailed, ex.Code);
+        }
+    }
+
+    [Theory]
+    [InlineData(ErrorCode.Unknown, 0)]
+    [InlineData(ErrorCode.FileCorrupted, 1000)]
+    [InlineData(ErrorCode.ChecksumFailed, 1001)]
+    [InlineData(ErrorCode.IncompatibleFileFormat, 1002)]
+    [InlineData(ErrorCode.ParseFailed, 2000)]
+    [InlineData(ErrorCode.SyntaxError, 2001)]
+    [InlineData(ErrorCode.TransactionConflict, 3000)]
+    [InlineData(ErrorCode.Deadlock, 3001)]
+    [InlineData(ErrorCode.TransactionError, 3002)]
+    [InlineData(ErrorCode.TableExists, 4000)]
+    [InlineData(ErrorCode.TableNotFound, 4001)]
+    [InlineData(ErrorCode.PrimaryKeyConflict, 4002)]
+    [InlineData(ErrorCode.ConstraintViolation, 4003)]
+    [InlineData(ErrorCode.ShardNotFound, 4004)]
+    [InlineData(ErrorCode.ShardLimitExceeded, 4005)]
+    [InlineData(ErrorCode.StreamNotFound, 4006)]
+    [InlineData(ErrorCode.ConsumerGroupNotFound, 4007)]
+    [InlineData(ErrorCode.MessageExpired, 4008)]
+    [InlineData(ErrorCode.KeyNotFound, 4009)]
+    [InlineData(ErrorCode.KeyExpired, 4010)]
+    [InlineData(ErrorCode.NotSupported, 5000)]
+    [InlineData(ErrorCode.InvalidArgument, 5001)]
+    [InlineData(ErrorCode.IoError, 6000)]
+    [InlineData(ErrorCode.DiskFull, 6001)]
+    [InlineData(ErrorCode.ConnectionFailed, 7000)]
+    [InlineData(ErrorCode.AuthenticationFailed, 7001)]
+    [InlineData(ErrorCode.ProtocolError, 7002)]
+    [InlineData(ErrorCode.SessionExpired, 7003)]
+    [InlineData(ErrorCode.ReplicationError, 8000)]
+    [InlineData(ErrorCode.NodeNotFound, 8001)]
+    [InlineData(ErrorCode.NotMaster, 8002)]
+    [InlineData(ErrorCode.ReplicationLag, 8003)]
+    public void TestErrorCodeValues(ErrorCode code, Int32 expectedValue)
+    {
+        Assert.Equal(expectedValue, (Int32)code);
+    }
+
+    [Fact]
+    public void TestErrorCodeCategories()
+    {
+        // 文件错误 (1000-1999)
+        Assert.InRange((Int32)ErrorCode.FileCorrupted, 1000, 1999);
+        Assert.InRange((Int32)ErrorCode.ChecksumFailed, 1000, 1999);
+
+        // 解析错误 (2000-2999)
+        Assert.InRange((Int32)ErrorCode.ParseFailed, 2000, 2999);
+        Assert.InRange((Int32)ErrorCode.SyntaxError, 2000, 2999);
+
+        // 事务错误 (3000-3999)
+        Assert.InRange((Int32)ErrorCode.TransactionConflict, 3000, 3999);
+        Assert.InRange((Int32)ErrorCode.Deadlock, 3000, 3999);
+
+        // 数据错误 (4000-4999)
+        Assert.InRange((Int32)ErrorCode.TableNotFound, 4000, 4999);
+        Assert.InRange((Int32)ErrorCode.PrimaryKeyConflict, 4000, 4999);
+
+        // 通用错误 (5000-5999)
+        Assert.InRange((Int32)ErrorCode.NotSupported, 5000, 5999);
+        Assert.InRange((Int32)ErrorCode.InvalidArgument, 5000, 5999);
+
+        // I/O 错误 (6000-6999)
+        Assert.InRange((Int32)ErrorCode.IoError, 6000, 6999);
+        Assert.InRange((Int32)ErrorCode.DiskFull, 6000, 6999);
+
+        // 网络错误 (7000-7999)
+        Assert.InRange((Int32)ErrorCode.ConnectionFailed, 7000, 7999);
+        Assert.InRange((Int32)ErrorCode.ProtocolError, 7000, 7999);
+
+        // 集群错误 (8000-8999)
+        Assert.InRange((Int32)ErrorCode.ReplicationError, 8000, 8999);
+        Assert.InRange((Int32)ErrorCode.NotMaster, 8000, 8999);
+    }
+
+    [Fact]
+    public void TestInheritanceFromException()
+    {
+        var exception = new NovaException(ErrorCode.Unknown, "Test");
+        Assert.IsAssignableFrom<Exception>(exception);
+    }
+
+    [Fact]
+    public void TestMultipleCatchBlocks()
+    {
+        Exception? caughtException = null;
+
+        try
+        {
+            throw new NovaException(ErrorCode.TableNotFound, "Table missing");
+        }
+        catch (NovaException ex)
+        {
+            caughtException = ex;
+        }
+        catch (Exception)
+        {
+            Assert.Fail("Should have caught NovaException specifically");
+        }
+
+        Assert.NotNull(caughtException);
+        Assert.IsType<NovaException>(caughtException);
+    }
+}
Modified +249 -11
diff --git a/XUnitTest/Storage/DatabaseDirectoryTests.cs b/XUnitTest/Storage/DatabaseDirectoryTests.cs
index ad3148a..bc68438 100644
--- a/XUnitTest/Storage/DatabaseDirectoryTests.cs
+++ b/XUnitTest/Storage/DatabaseDirectoryTests.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.IO;
 using System.Linq;
 using NewLife.NovaDb.Core;
@@ -9,7 +9,7 @@ namespace XUnitTest.Storage;
 
 public class DatabaseDirectoryTests : IDisposable
 {
-    private readonly string _testPath;
+    private readonly String _testPath;
     private readonly DbOptions _options;
 
     public DatabaseDirectoryTests()
@@ -30,6 +30,28 @@ public class DatabaseDirectoryTests : IDisposable
         }
     }
 
+    #region 构造函数
+    [Fact]
+    public void TestConstructorNullPath()
+    {
+        Assert.Throws<ArgumentNullException>(() => new DatabaseDirectory(null!, _options));
+    }
+
+    [Fact]
+    public void TestConstructorNullOptions()
+    {
+        Assert.Throws<ArgumentNullException>(() => new DatabaseDirectory(_testPath, null!));
+    }
+
+    [Fact]
+    public void TestConstructorEmptyPath()
+    {
+        Assert.Throws<ArgumentException>(() => new DatabaseDirectory("", _options));
+        Assert.Throws<ArgumentException>(() => new DatabaseDirectory("  ", _options));
+    }
+    #endregion
+
+    #region Create
     [Fact]
     public void TestCreateDatabase()
     {
@@ -37,11 +59,40 @@ public class DatabaseDirectoryTests : IDisposable
         db.Create();
 
         Assert.True(Directory.Exists(_testPath));
-        Assert.True(Directory.Exists(db.SystemPath));
         Assert.True(File.Exists(Path.Combine(_testPath, "nova.db")));
     }
 
     [Fact]
+    public void TestCreateDatabaseVerifyMetadata()
+    {
+        var db = new DatabaseDirectory(_testPath, _options);
+        db.Create();
+
+        // 验证元数据文件内容
+        var metaBytes = File.ReadAllBytes(Path.Combine(_testPath, "nova.db"));
+        Assert.Equal(FileHeader.HeaderSize, metaBytes.Length);
+
+        var header = FileHeader.FromBytes(metaBytes);
+        Assert.Equal(1, header.Version);
+        Assert.Equal(FileType.Data, header.FileType);
+        Assert.Equal(4096u, header.PageSize);
+        Assert.True(header.CreatedAt > 0);
+    }
+
+    [Fact]
+    public void TestCreateDatabaseAlreadyExists()
+    {
+        var db = new DatabaseDirectory(_testPath, _options);
+        db.Create();
+
+        var ex = Assert.Throws<NovaException>(() => db.Create());
+        Assert.Equal(ErrorCode.InvalidArgument, ex.Code);
+        Assert.Contains("already exists", ex.Message);
+    }
+    #endregion
+
+    #region Open
+    [Fact]
     public void TestOpenDatabase()
     {
         var db = new DatabaseDirectory(_testPath, _options);
@@ -52,29 +103,83 @@ public class DatabaseDirectoryTests : IDisposable
     }
 
     [Fact]
-    public void TestDropDatabase()
+    public void TestOpenDatabaseNotExists()
+    {
+        var db = new DatabaseDirectory(_testPath, _options);
+
+        var ex = Assert.Throws<NovaException>(() => db.Open());
+        Assert.Equal(ErrorCode.InvalidArgument, ex.Code);
+        Assert.Contains("does not exist", ex.Message);
+    }
+
+    [Fact]
+    public void TestOpenDatabaseMissingMetadata()
+    {
+        // 创建目录但不写入元数据
+        Directory.CreateDirectory(_testPath);
+
+        var db = new DatabaseDirectory(_testPath, _options);
+        var ex = Assert.Throws<NovaException>(() => db.Open());
+        Assert.Equal(ErrorCode.FileCorrupted, ex.Code);
+        Assert.Contains("metadata file not found", ex.Message);
+    }
+
+    [Fact]
+    public void TestOpenDatabaseCorruptedMetadata()
+    {
+        // 创建目录并写入损坏的元数据
+        Directory.CreateDirectory(_testPath);
+        var metaPath = Path.Combine(_testPath, "nova.db");
+        File.WriteAllBytes(metaPath, new Byte[32]); // 全零,魔数不对
+
+        var db = new DatabaseDirectory(_testPath, _options);
+        Assert.Throws<NovaException>(() => db.Open());
+    }
+
+    [Fact]
+    public void TestOpenDatabaseUnsupportedVersion()
     {
         var db = new DatabaseDirectory(_testPath, _options);
         db.Create();
 
-        Assert.True(Directory.Exists(_testPath));
+        // 篡改版本号为 99
+        var metaPath = Path.Combine(_testPath, "nova.db");
+        var metaBytes = File.ReadAllBytes(metaPath);
+        BitConverter.GetBytes((UInt16)99).CopyTo(metaBytes, 4);
+        File.WriteAllBytes(metaPath, metaBytes);
 
-        db.Drop();
+        var db2 = new DatabaseDirectory(_testPath, _options);
+        var ex = Assert.Throws<NovaException>(() => db2.Open());
+        Assert.Equal(ErrorCode.IncompatibleFileFormat, ex.Code);
+        Assert.Contains("Unsupported database version", ex.Message);
+    }
 
-        Assert.False(Directory.Exists(_testPath));
+    [Fact]
+    public void TestOpenDatabaseTruncatedMetadata()
+    {
+        // 创建目录并写入过短的元数据
+        Directory.CreateDirectory(_testPath);
+        var metaPath = Path.Combine(_testPath, "nova.db");
+        File.WriteAllBytes(metaPath, new Byte[10]); // 只有 10 字节
+
+        var db = new DatabaseDirectory(_testPath, _options);
+        Assert.ThrowsAny<Exception>(() => db.Open()); // FileHeader.FromBytes 拒绝短 buffer
     }
+    #endregion
 
+    #region ListTables
     [Fact]
     public void TestListTables()
     {
         var db = new DatabaseDirectory(_testPath, _options);
         db.Create();
 
-        var table1 = db.GetTableDirectory("Users");
-        table1.Create();
+        // 创建表文件(模拟表的存在)
+        var table1 = db.GetTableFileManager("Users");
+        File.WriteAllText(table1.GetDataFilePath(), "");
 
-        var table2 = db.GetTableDirectory("Orders");
-        table2.Create();
+        var table2 = db.GetTableFileManager("Orders");
+        File.WriteAllText(table2.GetDataFilePath(), "");
 
         var tables = db.ListTables().ToList();
 
@@ -82,4 +187,137 @@ public class DatabaseDirectoryTests : IDisposable
         Assert.Contains("Users", tables);
         Assert.Contains("Orders", tables);
     }
+
+    [Fact]
+    public void TestListTablesEmpty()
+    {
+        var db = new DatabaseDirectory(_testPath, _options);
+        db.Create();
+
+        var tables = db.ListTables().ToList();
+        Assert.Empty(tables);
+    }
+
+    [Fact]
+    public void TestListTablesExcludesSystemTables()
+    {
+        var db = new DatabaseDirectory(_testPath, _options);
+        db.Create();
+
+        // 创建系统表文件
+        File.WriteAllText(Path.Combine(_testPath, "_sys_tables.data"), "");
+        File.WriteAllText(Path.Combine(_testPath, "_sys_columns.data"), "");
+
+        // 创建用户表
+        File.WriteAllText(Path.Combine(_testPath, "Users.data"), "");
+
+        var tables = db.ListTables().ToList();
+        Assert.Single(tables);
+        Assert.Contains("Users", tables);
+    }
+
+    [Fact]
+    public void TestListTablesWithShardFiles()
+    {
+        var db = new DatabaseDirectory(_testPath, _options);
+        db.Create();
+
+        // 同一张表有多个分片文件
+        File.WriteAllText(Path.Combine(_testPath, "BigTable.data"), "");
+        File.WriteAllText(Path.Combine(_testPath, "BigTable_0.data"), "");
+        File.WriteAllText(Path.Combine(_testPath, "BigTable_1.data"), "");
+
+        var tables = db.ListTables().ToList();
+        Assert.Single(tables); // 仍然只显示一张表
+        Assert.Contains("BigTable", tables);
+    }
+
+    [Fact]
+    public void TestListTablesDirectoryNotExists()
+    {
+        var db = new DatabaseDirectory(_testPath, _options);
+
+        // 目录不存在时返回空
+        var tables = db.ListTables().ToList();
+        Assert.Empty(tables);
+    }
+
+    [Fact]
+    public void TestListTablesReturnsSorted()
+    {
+        var db = new DatabaseDirectory(_testPath, _options);
+        db.Create();
+
+        File.WriteAllText(Path.Combine(_testPath, "Zebra.data"), "");
+        File.WriteAllText(Path.Combine(_testPath, "Alpha.data"), "");
+        File.WriteAllText(Path.Combine(_testPath, "Middle.data"), "");
+
+        var tables = db.ListTables().ToList();
+        Assert.Equal(3, tables.Count);
+        Assert.Equal("Alpha", tables[0]);
+        Assert.Equal("Middle", tables[1]);
+        Assert.Equal("Zebra", tables[2]);
+    }
+    #endregion
+
+    #region GetTableFileManager
+    [Fact]
+    public void TestGetTableFileManager()
+    {
+        var db = new DatabaseDirectory(_testPath, _options);
+        db.Create();
+
+        var manager = db.GetTableFileManager("Products");
+        Assert.Equal("Products", manager.TableName);
+        Assert.Equal(_testPath, manager.DatabasePath);
+    }
+
+    [Fact]
+    public void TestGetTableFileManagerEmptyName()
+    {
+        var db = new DatabaseDirectory(_testPath, _options);
+        Assert.Throws<ArgumentException>(() => db.GetTableFileManager(""));
+        Assert.Throws<ArgumentException>(() => db.GetTableFileManager("  "));
+    }
+    #endregion
+
+    #region Drop
+    [Fact]
+    public void TestDropDatabase()
+    {
+        var db = new DatabaseDirectory(_testPath, _options);
+        db.Create();
+
+        Assert.True(Directory.Exists(_testPath));
+
+        db.Drop();
+
+        Assert.False(Directory.Exists(_testPath));
+    }
+
+    [Fact]
+    public void TestDropDatabaseNotExists()
+    {
+        var db = new DatabaseDirectory(_testPath, _options);
+
+        // 删除不存在的库不应抛异常
+        db.Drop();
+    }
+
+    [Fact]
+    public void TestDropDatabaseWithFiles()
+    {
+        var db = new DatabaseDirectory(_testPath, _options);
+        db.Create();
+
+        // 添加一些文件
+        File.WriteAllText(Path.Combine(_testPath, "Users.data"), "test");
+        File.WriteAllText(Path.Combine(_testPath, "Users.idx"), "test");
+        File.WriteAllText(Path.Combine(_testPath, "Users.wal"), "test");
+
+        db.Drop();
+
+        Assert.False(Directory.Exists(_testPath));
+    }
+    #endregion
 }
Modified +179 -3
diff --git a/XUnitTest/Storage/FileHeaderTests.cs b/XUnitTest/Storage/FileHeaderTests.cs
index 302300d..6163d11 100644
--- a/XUnitTest/Storage/FileHeaderTests.cs
+++ b/XUnitTest/Storage/FileHeaderTests.cs
@@ -1,4 +1,5 @@
-using System;
+using System;
+using NewLife.NovaDb.Core;
 using NewLife.NovaDb.Storage;
 using Xunit;
 
@@ -19,6 +20,7 @@ public class FileHeaderTests
         };
 
         var bytes = header.ToBytes();
+        Assert.Equal(FileHeader.HeaderSize, bytes.Length);
         Assert.Equal(32, bytes.Length);
 
         var deserialized = FileHeader.FromBytes(bytes);
@@ -49,9 +51,183 @@ public class FileHeaderTests
     [Fact]
     public void TestInvalidMagicNumber()
     {
-        var bytes = new byte[32];
+        var bytes = new Byte[32];
         BitConverter.GetBytes(0xDEADBEEFu).CopyTo(bytes, 0);
 
-        Assert.Throws<NewLife.NovaDb.Core.NovaException>(() => FileHeader.FromBytes(bytes));
+        var ex = Assert.Throws<NovaException>(() => FileHeader.FromBytes(bytes));
+        Assert.Equal(ErrorCode.FileCorrupted, ex.Code);
+        Assert.Contains("Invalid magic number", ex.Message);
+    }
+
+    [Fact]
+    public void TestNullBuffer()
+    {
+        Assert.Throws<ArgumentNullException>(() => FileHeader.FromBytes(null!));
+    }
+
+    [Fact]
+    public void TestBufferTooShort()
+    {
+        var bytes = new Byte[31]; // 少于 32 字节
+        var ex = Assert.Throws<ArgumentException>(() => FileHeader.FromBytes(bytes));
+        Assert.Contains("too short", ex.Message);
+    }
+
+    [Fact]
+    public void TestInvalidFileType()
+    {
+        var header = new FileHeader
+        {
+            FileType = FileType.Data,
+            PageSize = 4096
+        };
+        var bytes = header.ToBytes();
+
+        // 设置无效的文件类型(0 或 > 3)
+        bytes[6] = 99;
+
+        var ex = Assert.Throws<NovaException>(() => FileHeader.FromBytes(bytes));
+        Assert.Equal(ErrorCode.FileCorrupted, ex.Code);
+        Assert.Contains("Invalid file type", ex.Message);
+    }
+
+    [Fact]
+    public void TestInvalidPageSizeZero()
+    {
+        var header = new FileHeader
+        {
+            FileType = FileType.Data,
+            PageSize = 4096
+        };
+        var bytes = header.ToBytes();
+
+        // 设置 PageSize 为 0
+        BitConverter.GetBytes(0u).CopyTo(bytes, 8);
+
+        var ex = Assert.Throws<NovaException>(() => FileHeader.FromBytes(bytes));
+        Assert.Equal(ErrorCode.FileCorrupted, ex.Code);
+        Assert.Contains("Invalid page size", ex.Message);
+    }
+
+    [Fact]
+    public void TestInvalidPageSizeTooLarge()
+    {
+        var header = new FileHeader
+        {
+            FileType = FileType.Data,
+            PageSize = 4096
+        };
+        var bytes = header.ToBytes();
+
+        // 设置 PageSize > 64KB
+        BitConverter.GetBytes(128u * 1024).CopyTo(bytes, 8);
+
+        var ex = Assert.Throws<NovaException>(() => FileHeader.FromBytes(bytes));
+        Assert.Equal(ErrorCode.FileCorrupted, ex.Code);
+        Assert.Contains("Invalid page size", ex.Message);
+    }
+
+    [Fact]
+    public void TestDifferentFileTypes()
+    {
+        foreach (FileType type in Enum.GetValues(typeof(FileType)))
+        {
+            var header = new FileHeader
+            {
+                FileType = type,
+                PageSize = 4096
+            };
+
+            var bytes = header.ToBytes();
+            var deserialized = FileHeader.FromBytes(bytes);
+
+            Assert.Equal(type, deserialized.FileType);
+        }
+    }
+
+    [Fact]
+    public void TestBoundaryPageSizes()
+    {
+        // 最小有效 PageSize
+        var header1 = new FileHeader { FileType = FileType.Data, PageSize = 1 };
+        var bytes1 = header1.ToBytes();
+        var deserialized1 = FileHeader.FromBytes(bytes1);
+        Assert.Equal(1u, deserialized1.PageSize);
+
+        // 最大有效 PageSize (64KB)
+        var header2 = new FileHeader { FileType = FileType.Data, PageSize = 64 * 1024 };
+        var bytes2 = header2.ToBytes();
+        var deserialized2 = FileHeader.FromBytes(bytes2);
+        Assert.Equal(64u * 1024, deserialized2.PageSize);
+    }
+
+    [Fact]
+    public void TestVersionPersistence()
+    {
+        var header = new FileHeader
+        {
+            Version = 42,
+            FileType = FileType.Index,
+            PageSize = 8192
+        };
+
+        var bytes = header.ToBytes();
+        var deserialized = FileHeader.FromBytes(bytes);
+
+        Assert.Equal(42, deserialized.Version);
+    }
+
+    [Fact]
+    public void TestCreatedAtPersistence()
+    {
+        var now = DateTime.UtcNow.Ticks;
+        var header = new FileHeader
+        {
+            FileType = FileType.Wal,
+            PageSize = 4096,
+            CreatedAt = now
+        };
+
+        var bytes = header.ToBytes();
+        var deserialized = FileHeader.FromBytes(bytes);
+
+        Assert.Equal(now, deserialized.CreatedAt);
+    }
+
+    [Fact]
+    public void TestOptionsHashPersistence()
+    {
+        var header = new FileHeader
+        {
+            FileType = FileType.Data,
+            PageSize = 4096,
+            OptionsHash = 0xABCD1234
+        };
+
+        var bytes = header.ToBytes();
+        var deserialized = FileHeader.FromBytes(bytes);
+
+        Assert.Equal(0xABCD1234u, deserialized.OptionsHash);
+    }
+
+    [Fact]
+    public void TestReservedBytesAreZero()
+    {
+        var header = new FileHeader
+        {
+            FileType = FileType.Data,
+            PageSize = 4096
+        };
+
+        var bytes = header.ToBytes();
+
+        // Reserved byte at offset 7
+        Assert.Equal(0, bytes[7]);
+
+        // Reserved 8 bytes at offset 24-31
+        for (var i = 24; i < 32; i++)
+        {
+            Assert.Equal(0, bytes[i]);
+        }
     }
 }
Modified +290 -15
diff --git a/XUnitTest/Storage/MmfPagerTests.cs b/XUnitTest/Storage/MmfPagerTests.cs
index f87b874..b77503b 100644
--- a/XUnitTest/Storage/MmfPagerTests.cs
+++ b/XUnitTest/Storage/MmfPagerTests.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.IO;
 using NewLife.NovaDb.Core;
 using NewLife.NovaDb.Storage;
@@ -8,7 +8,7 @@ namespace XUnitTest.Storage;
 
 public class MmfPagerTests : IDisposable
 {
-    private readonly string _testFile;
+    private readonly String _testFile;
 
     public MmfPagerTests()
     {
@@ -23,6 +23,22 @@ public class MmfPagerTests : IDisposable
         }
     }
 
+    #region 构造函数
+    [Fact]
+    public void TestConstructorNullPath()
+    {
+        Assert.Throws<ArgumentNullException>(() => new MmfPager(null!, 4096));
+    }
+
+    [Fact]
+    public void TestConstructorInvalidPageSize()
+    {
+        Assert.Throws<ArgumentOutOfRangeException>(() => new MmfPager(_testFile, 0));
+        Assert.Throws<ArgumentOutOfRangeException>(() => new MmfPager(_testFile, -1));
+    }
+    #endregion
+
+    #region Open
     [Fact]
     public void TestCreateAndOpenPager()
     {
@@ -41,6 +57,34 @@ public class MmfPagerTests : IDisposable
     }
 
     [Fact]
+    public void TestOpenExistingFileWithMismatchedPageSize()
+    {
+        // 创建文件
+        var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+        using (var pager1 = new MmfPager(_testFile, 4096))
+        {
+            pager1.Open(header);
+        }
+
+        // 用不同 PageSize 打开
+        using var pager2 = new MmfPager(_testFile, 8192);
+        var ex = Assert.Throws<NovaException>(() => pager2.Open());
+        Assert.Equal(ErrorCode.IncompatibleFileFormat, ex.Code);
+        Assert.Contains("Page size mismatch", ex.Message);
+    }
+
+    [Fact]
+    public void TestOpenAfterDispose()
+    {
+        var pager = new MmfPager(_testFile, 4096);
+        pager.Dispose();
+
+        Assert.Throws<ObjectDisposedException>(() => pager.Open());
+    }
+    #endregion
+
+    #region ReadPage / WritePage
+    [Fact]
     public void TestWriteAndReadPage()
     {
         var header = new FileHeader
@@ -53,8 +97,8 @@ public class MmfPagerTests : IDisposable
         using var pager = new MmfPager(_testFile, 4096, enableChecksum: false);
         pager.Open(header);
 
-        // 创建测试页
-        var pageData = new byte[4096];
+        // 构建测试页
+        var pageData = new Byte[4096];
         var pageHeader = new PageHeader
         {
             PageId = 0,
@@ -64,29 +108,195 @@ public class MmfPagerTests : IDisposable
         };
 
         var headerBytes = pageHeader.ToBytes();
-        Buffer.BlockCopy(headerBytes, 0, pageData, 0, 32);
+        Buffer.BlockCopy(headerBytes, 0, pageData, 0, PageHeader.HeaderSize);
 
-        // 写入一些测试数据
-        for (int i = 32; i < 132; i++)
+        for (var i = 32; i < 132; i++)
         {
-            pageData[i] = (byte)(i % 256);
+            pageData[i] = (Byte)(i % 256);
         }
 
-        // 写入页
         pager.WritePage(0, pageData);
 
-        // 读取页
         var readData = pager.ReadPage(0);
 
-        // 验证数据
         Assert.Equal(pageData.Length, readData.Length);
-        for (int i = 32; i < 132; i++)
+        for (var i = 32; i < 132; i++)
         {
             Assert.Equal(pageData[i], readData[i]);
         }
     }
 
     [Fact]
+    public void TestWriteMultiplePages()
+    {
+        var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+
+        using var pager = new MmfPager(_testFile, 4096, enableChecksum: false);
+        pager.Open(header);
+
+        // 写入 3 个页面
+        for (UInt64 i = 0; i < 3; i++)
+        {
+            var data = new Byte[4096];
+            data[32] = (Byte)(i + 100); // 每页标记不同数据
+            pager.WritePage(i, data);
+        }
+
+        // 验证每个页面数据独立
+        for (UInt64 i = 0; i < 3; i++)
+        {
+            var data = pager.ReadPage(i);
+            Assert.Equal((Byte)(i + 100), data[32]);
+        }
+    }
+
+    [Fact]
+    public void TestReadPageOutOfRange()
+    {
+        var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+
+        using var pager = new MmfPager(_testFile, 4096, enableChecksum: false);
+        pager.Open(header);
+
+        // 没有写任何页,读取 pageId=0 应越界
+        Assert.Throws<ArgumentOutOfRangeException>(() => pager.ReadPage(0));
+    }
+
+    [Fact]
+    public void TestReadPageNotOpened()
+    {
+        using var pager = new MmfPager(_testFile, 4096);
+        Assert.Throws<InvalidOperationException>(() => pager.ReadPage(0));
+    }
+
+    [Fact]
+    public void TestWritePageNotOpened()
+    {
+        using var pager = new MmfPager(_testFile, 4096);
+        var data = new Byte[4096];
+        Assert.Throws<InvalidOperationException>(() => pager.WritePage(0, data));
+    }
+
+    [Fact]
+    public void TestWritePageNullData()
+    {
+        var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+        using var pager = new MmfPager(_testFile, 4096);
+        pager.Open(header);
+
+        Assert.Throws<ArgumentNullException>(() => pager.WritePage(0, null!));
+    }
+
+    [Fact]
+    public void TestWritePageWrongSize()
+    {
+        var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+        using var pager = new MmfPager(_testFile, 4096);
+        pager.Open(header);
+
+        Assert.Throws<ArgumentException>(() => pager.WritePage(0, new Byte[1024])); // 太小
+        Assert.Throws<ArgumentException>(() => pager.WritePage(0, new Byte[8192])); // 太大
+    }
+
+    [Fact]
+    public void TestReadAfterDispose()
+    {
+        var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+        var pager = new MmfPager(_testFile, 4096);
+        pager.Open(header);
+        pager.WritePage(0, new Byte[4096]);
+        pager.Dispose();
+
+        Assert.Throws<ObjectDisposedException>(() => pager.ReadPage(0));
+    }
+
+    [Fact]
+    public void TestWriteAfterDispose()
+    {
+        var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+        var pager = new MmfPager(_testFile, 4096);
+        pager.Open(header);
+        pager.Dispose();
+
+        Assert.Throws<ObjectDisposedException>(() => pager.WritePage(0, new Byte[4096]));
+    }
+    #endregion
+
+    #region Checksum
+    [Fact]
+    public void TestWriteAndReadWithChecksum()
+    {
+        var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+
+        using var pager = new MmfPager(_testFile, 4096, enableChecksum: true);
+        pager.Open(header);
+
+        var pageData = new Byte[4096];
+        var pageHeader = new PageHeader
+        {
+            PageId = 0,
+            PageType = PageType.Data,
+            Lsn = 1,
+            DataLength = 50
+        };
+
+        Buffer.BlockCopy(pageHeader.ToBytes(), 0, pageData, 0, PageHeader.HeaderSize);
+        for (var i = 32; i < 82; i++)
+        {
+            pageData[i] = (Byte)(i * 3);
+        }
+
+        // WritePage 会自动计算校验和
+        pager.WritePage(0, pageData);
+
+        // ReadPage 会自动验证校验和
+        var readData = pager.ReadPage(0);
+        Assert.Equal(4096, readData.Length);
+    }
+
+    [Fact]
+    public void TestChecksumCorruption()
+    {
+        var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+
+        // 先写入数据
+        using (var pager = new MmfPager(_testFile, 4096, enableChecksum: true))
+        {
+            pager.Open(header);
+
+            var pageData = new Byte[4096];
+            var pageHeader = new PageHeader
+            {
+                PageId = 0,
+                PageType = PageType.Data,
+                Lsn = 1,
+                DataLength = 10
+            };
+
+            Buffer.BlockCopy(pageHeader.ToBytes(), 0, pageData, 0, PageHeader.HeaderSize);
+            pager.WritePage(0, pageData);
+        }
+
+        // 篡改文件中的页数据
+        using (var fs = File.Open(_testFile, FileMode.Open, FileAccess.ReadWrite))
+        {
+            // 在 FileHeader(32B) + PageHeader(32B) 后篡改一个字节
+            fs.Seek(FileHeader.HeaderSize + PageHeader.HeaderSize, SeekOrigin.Begin);
+            fs.WriteByte(0xFF);
+        }
+
+        // 重新打开并读取应触发校验和错误
+        using var pager2 = new MmfPager(_testFile, 4096, enableChecksum: true);
+        pager2.Open();
+
+        var ex = Assert.Throws<NovaException>(() => pager2.ReadPage(0));
+        Assert.Equal(ErrorCode.ChecksumFailed, ex.Code);
+        Assert.Contains("Checksum mismatch", ex.Message);
+    }
+    #endregion
+
+    #region PageCount / Flush
+    [Fact]
     public void TestPageCount()
     {
         var header = new FileHeader
@@ -101,11 +311,76 @@ public class MmfPagerTests : IDisposable
 
         Assert.Equal(0UL, pager.PageCount);
 
-        var pageData = new byte[4096];
-        pager.WritePage(0, pageData);
+        pager.WritePage(0, new Byte[4096]);
         Assert.Equal(1UL, pager.PageCount);
 
-        pager.WritePage(1, pageData);
+        pager.WritePage(1, new Byte[4096]);
         Assert.Equal(2UL, pager.PageCount);
     }
+
+    [Fact]
+    public void TestPageCountBeforeOpen()
+    {
+        using var pager = new MmfPager(_testFile, 4096);
+        Assert.Equal(0UL, pager.PageCount); // 未打开返回 0
+    }
+
+    [Fact]
+    public void TestFlush()
+    {
+        var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+        using var pager = new MmfPager(_testFile, 4096, enableChecksum: false);
+        pager.Open(header);
+
+        pager.WritePage(0, new Byte[4096]);
+        pager.Flush(); // 不应抛异常
+    }
+
+    [Fact]
+    public void TestFlushBeforeOpen()
+    {
+        using var pager = new MmfPager(_testFile, 4096);
+        pager.Flush(); // 未打开时不应抛异常
+    }
+    #endregion
+
+    #region DoubleDispose
+    [Fact]
+    public void TestDoubleDispose()
+    {
+        var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+        var pager = new MmfPager(_testFile, 4096);
+        pager.Open(header);
+
+        pager.Dispose();
+        pager.Dispose(); // 第二次释放不应抛异常
+    }
+    #endregion
+
+    #region 跨实例持久化
+    [Fact]
+    public void TestDataPersistsAcrossInstances()
+    {
+        var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+
+        // 第一个实例写数据
+        using (var pager1 = new MmfPager(_testFile, 4096, enableChecksum: false))
+        {
+            pager1.Open(header);
+            var data = new Byte[4096];
+            data[32] = 42;
+            data[100] = 99;
+            pager1.WritePage(0, data);
+        }
+
+        // 第二个实例读数据
+        using (var pager2 = new MmfPager(_testFile, 4096, enableChecksum: false))
+        {
+            pager2.Open();
+            var data = pager2.ReadPage(0);
+            Assert.Equal(42, data[32]);
+            Assert.Equal(99, data[100]);
+        }
+    }
+    #endregion
 }
Modified +240 -17
diff --git a/XUnitTest/Storage/PageCacheTests.cs b/XUnitTest/Storage/PageCacheTests.cs
index 1234909..b57eda2 100644
--- a/XUnitTest/Storage/PageCacheTests.cs
+++ b/XUnitTest/Storage/PageCacheTests.cs
@@ -1,3 +1,5 @@
+using System;
+using System.Threading.Tasks;
 using NewLife.NovaDb.Storage;
 using Xunit;
 
@@ -5,13 +7,45 @@ namespace XUnitTest.Storage;
 
 public class PageCacheTests
 {
+    #region 构造函数
+    [Fact]
+    public void TestConstructorValidCapacity()
+    {
+        var cache = new PageCache(100);
+        Assert.Equal(100, cache.Capacity);
+        Assert.Equal(0, cache.Count);
+    }
+
+    [Fact]
+    public void TestConstructorInvalidCapacity()
+    {
+        Assert.Throws<ArgumentOutOfRangeException>(() => new PageCache(0));
+        Assert.Throws<ArgumentOutOfRangeException>(() => new PageCache(-1));
+    }
+
+    [Fact]
+    public void TestConstructorMinCapacity()
+    {
+        var cache = new PageCache(1);
+        Assert.Equal(1, cache.Capacity);
+
+        cache.Put(1, new Byte[] { 1 });
+        cache.Put(2, new Byte[] { 2 }); // 淘汰 1
+
+        Assert.Equal(1, cache.Count);
+        Assert.False(cache.TryGet(1, out _));
+        Assert.True(cache.TryGet(2, out _));
+    }
+    #endregion
+
+    #region Put / TryGet
     [Fact]
     public void TestPutAndGet()
     {
         var cache = new PageCache(3);
 
-        var data1 = new byte[] { 1, 2, 3 };
-        var data2 = new byte[] { 4, 5, 6 };
+        var data1 = new Byte[] { 1, 2, 3 };
+        var data2 = new Byte[] { 4, 5, 6 };
 
         cache.Put(1, data1);
         cache.Put(2, data2);
@@ -26,47 +60,164 @@ public class PageCacheTests
     }
 
     [Fact]
+    public void TestPutNullData()
+    {
+        var cache = new PageCache(3);
+        Assert.Throws<ArgumentNullException>(() => cache.Put(1, null!));
+    }
+
+    [Fact]
+    public void TestPutUpdateExisting()
+    {
+        var cache = new PageCache(3);
+
+        cache.Put(1, new Byte[] { 10 });
+        cache.Put(1, new Byte[] { 20 }); // 更新同一个 key
+
+        Assert.Equal(1, cache.Count);
+        Assert.True(cache.TryGet(1, out var data));
+        Assert.Equal(20, data![0]);
+    }
+
+    [Fact]
+    public void TestGetMissReturnsNull()
+    {
+        var cache = new PageCache(3);
+
+        Assert.False(cache.TryGet(999, out var data));
+        Assert.Null(data);
+    }
+    #endregion
+
+    #region LRU 淘汰
+    [Fact]
     public void TestLruEviction()
     {
         var cache = new PageCache(2);
 
-        var data1 = new byte[] { 1 };
-        var data2 = new byte[] { 2 };
-        var data3 = new byte[] { 3 };
+        cache.Put(1, new Byte[] { 1 });
+        cache.Put(2, new Byte[] { 2 });
 
-        cache.Put(1, data1);
-        cache.Put(2, data2);
+        // 缓存已满,添加第三个应淘汰第一个(最久未访问)
+        cache.Put(3, new Byte[] { 3 });
 
-        // 缓存已满,添加第三个应淘汰第一个
-        cache.Put(3, data3);
-
-        Assert.False(cache.TryGet(1, out _)); // 应被淘汰
+        Assert.False(cache.TryGet(1, out _)); // 已淘汰
         Assert.True(cache.TryGet(2, out _));
         Assert.True(cache.TryGet(3, out _));
     }
 
     [Fact]
-    public void TestRemove()
+    public void TestLruEvictionAfterAccess()
+    {
+        var cache = new PageCache(2);
+
+        cache.Put(1, new Byte[] { 1 });
+        cache.Put(2, new Byte[] { 2 });
+
+        // 访问 key=1,让它变成最近使用
+        cache.TryGet(1, out _);
+
+        // 添加第三个,应淘汰 key=2(最久未访问)
+        cache.Put(3, new Byte[] { 3 });
+
+        Assert.True(cache.TryGet(1, out _));  // 被访问过,不淘汰
+        Assert.False(cache.TryGet(2, out _)); // 被淘汰
+        Assert.True(cache.TryGet(3, out _));
+    }
+
+    [Fact]
+    public void TestLruEvictionAfterUpdate()
+    {
+        var cache = new PageCache(2);
+
+        cache.Put(1, new Byte[] { 1 });
+        cache.Put(2, new Byte[] { 2 });
+
+        // 更新 key=1,让它变成最近使用
+        cache.Put(1, new Byte[] { 10 });
+
+        // 添加第三个,应淘汰 key=2
+        cache.Put(3, new Byte[] { 3 });
+
+        Assert.True(cache.TryGet(1, out var data));
+        Assert.Equal(10, data![0]); // 确认是更新后的值
+        Assert.False(cache.TryGet(2, out _));
+        Assert.True(cache.TryGet(3, out _));
+    }
+
+    [Fact]
+    public void TestLruEvictionSequence()
     {
         var cache = new PageCache(3);
 
-        var data = new byte[] { 1, 2, 3 };
-        cache.Put(1, data);
+        cache.Put(1, new Byte[] { 1 });
+        cache.Put(2, new Byte[] { 2 });
+        cache.Put(3, new Byte[] { 3 });
+
+        // 此时 LRU 序:3(头) → 2 → 1(尾)
+        // 再加入 4,应淘汰 1
+        cache.Put(4, new Byte[] { 4 });
+        Assert.False(cache.TryGet(1, out _));
 
+        // 再加入 5,应淘汰 2
+        cache.Put(5, new Byte[] { 5 });
+        Assert.False(cache.TryGet(2, out _));
+
+        Assert.True(cache.TryGet(3, out _));
+        Assert.True(cache.TryGet(4, out _));
+        Assert.True(cache.TryGet(5, out _));
+    }
+    #endregion
+
+    #region Remove
+    [Fact]
+    public void TestRemove()
+    {
+        var cache = new PageCache(3);
+
+        cache.Put(1, new Byte[] { 1, 2, 3 });
         Assert.True(cache.TryGet(1, out _));
 
+        var removed = cache.Remove(1);
+        Assert.True(removed);
+        Assert.False(cache.TryGet(1, out _));
+    }
+
+    [Fact]
+    public void TestRemoveNonExistent()
+    {
+        var cache = new PageCache(3);
+
+        var removed = cache.Remove(999);
+        Assert.False(removed);
+    }
+
+    [Fact]
+    public void TestRemoveFreesCapacity()
+    {
+        var cache = new PageCache(2);
+
+        cache.Put(1, new Byte[] { 1 });
+        cache.Put(2, new Byte[] { 2 });
+
+        // 移除一个后再添加不应触发淘汰
         cache.Remove(1);
+        cache.Put(3, new Byte[] { 3 });
 
-        Assert.False(cache.TryGet(1, out _));
+        Assert.True(cache.TryGet(2, out _));
+        Assert.True(cache.TryGet(3, out _));
+        Assert.Equal(2, cache.Count);
     }
+    #endregion
 
+    #region Clear
     [Fact]
     public void TestClear()
     {
         var cache = new PageCache(3);
 
-        cache.Put(1, new byte[] { 1 });
-        cache.Put(2, new byte[] { 2 });
+        cache.Put(1, new Byte[] { 1 });
+        cache.Put(2, new Byte[] { 2 });
 
         Assert.Equal(2, cache.Count);
 
@@ -76,4 +227,76 @@ public class PageCacheTests
         Assert.False(cache.TryGet(1, out _));
         Assert.False(cache.TryGet(2, out _));
     }
+
+    [Fact]
+    public void TestClearResetsHitMissCounters()
+    {
+        var cache = new PageCache(3);
+
+        cache.Put(1, new Byte[] { 1 });
+        cache.TryGet(1, out _); // hit
+        cache.TryGet(2, out _); // miss
+
+        Assert.Equal(1, cache.HitCount);
+        Assert.Equal(1, cache.MissCount);
+
+        cache.Clear();
+
+        Assert.Equal(0, cache.HitCount);
+        Assert.Equal(0, cache.MissCount);
+    }
+
+    [Fact]
+    public void TestClearThenReuse()
+    {
+        var cache = new PageCache(2);
+
+        cache.Put(1, new Byte[] { 1 });
+        cache.Put(2, new Byte[] { 2 });
+        cache.Clear();
+
+        // 清空后可正常复用
+        cache.Put(3, new Byte[] { 3 });
+        Assert.Equal(1, cache.Count);
+        Assert.True(cache.TryGet(3, out _));
+    }
+    #endregion
+
+    #region 统计
+    [Fact]
+    public void TestHitMissCounters()
+    {
+        var cache = new PageCache(3);
+
+        cache.Put(1, new Byte[] { 1 });
+        cache.Put(2, new Byte[] { 2 });
+
+        cache.TryGet(1, out _); // hit
+        cache.TryGet(2, out _); // hit
+        cache.TryGet(3, out _); // miss
+        cache.TryGet(4, out _); // miss
+        cache.TryGet(1, out _); // hit
+
+        Assert.Equal(3, cache.HitCount);
+        Assert.Equal(2, cache.MissCount);
+    }
+    #endregion
+
+    #region 并发安全
+    [Fact]
+    public void TestConcurrentAccess()
+    {
+        var cache = new PageCache(100);
+
+        // 并发写入和读取不应抛异常
+        Parallel.For(0, 1000, i =>
+        {
+            cache.Put((UInt64)i, new Byte[] { (Byte)(i % 256) });
+            cache.TryGet((UInt64)(i / 2), out _);
+        });
+
+        Assert.True(cache.Count <= 100); // 不超过容量
+        Assert.True(cache.Count > 0);
+    }
+    #endregion
 }
Modified +213 -0
diff --git a/XUnitTest/Storage/PageHeaderTests.cs b/XUnitTest/Storage/PageHeaderTests.cs
index 4416500..ad59288 100644
--- a/XUnitTest/Storage/PageHeaderTests.cs
+++ b/XUnitTest/Storage/PageHeaderTests.cs
@@ -1,3 +1,5 @@
+using System;
+using NewLife.NovaDb.Core;
 using NewLife.NovaDb.Storage;
 using Xunit;
 
@@ -18,6 +20,7 @@ public class PageHeaderTests
         };
 
         var bytes = header.ToBytes();
+        Assert.Equal(PageHeader.HeaderSize, bytes.Length);
         Assert.Equal(32, bytes.Length);
 
         var deserialized = PageHeader.FromBytes(bytes);
@@ -28,4 +31,214 @@ public class PageHeaderTests
         Assert.Equal(header.Checksum, deserialized.Checksum);
         Assert.Equal(header.DataLength, deserialized.DataLength);
     }
+
+    [Fact]
+    public void TestNullBuffer()
+    {
+        Assert.Throws<ArgumentNullException>(() => PageHeader.FromBytes(null!));
+    }
+
+    [Fact]
+    public void TestBufferTooShort()
+    {
+        var bytes = new Byte[31]; // 少于 32 字节
+        var ex = Assert.Throws<ArgumentException>(() => PageHeader.FromBytes(bytes));
+        Assert.Contains("too short", ex.Message);
+    }
+
+    [Fact]
+    public void TestInvalidPageType()
+    {
+        var header = new PageHeader
+        {
+            PageId = 1,
+            PageType = PageType.Data,
+            DataLength = 100
+        };
+        var bytes = header.ToBytes();
+
+        // 设置无效的页类型(> 4)
+        bytes[8] = 99;
+
+        var ex = Assert.Throws<NovaException>(() => PageHeader.FromBytes(bytes));
+        Assert.Equal(ErrorCode.FileCorrupted, ex.Code);
+        Assert.Contains("Invalid page type", ex.Message);
+    }
+
+    [Fact]
+    public void TestAllPageTypes()
+    {
+        foreach (PageType type in Enum.GetValues(typeof(PageType)))
+        {
+            var header = new PageHeader
+            {
+                PageId = 42,
+                PageType = type,
+                DataLength = 1024
+            };
+
+            var bytes = header.ToBytes();
+            var deserialized = PageHeader.FromBytes(bytes);
+
+            Assert.Equal(type, deserialized.PageType);
+        }
+    }
+
+    [Fact]
+    public void TestLargePageId()
+    {
+        var header = new PageHeader
+        {
+            PageId = UInt64.MaxValue,
+            PageType = PageType.Index,
+            DataLength = 512
+        };
+
+        var bytes = header.ToBytes();
+        var deserialized = PageHeader.FromBytes(bytes);
+
+        Assert.Equal(UInt64.MaxValue, deserialized.PageId);
+    }
+
+    [Fact]
+    public void TestLargeLsn()
+    {
+        var header = new PageHeader
+        {
+            PageId = 1,
+            PageType = PageType.Data,
+            Lsn = UInt64.MaxValue,
+            DataLength = 256
+        };
+
+        var bytes = header.ToBytes();
+        var deserialized = PageHeader.FromBytes(bytes);
+
+        Assert.Equal(UInt64.MaxValue, deserialized.Lsn);
+    }
+
+    [Fact]
+    public void TestLargeChecksum()
+    {
+        var header = new PageHeader
+        {
+            PageId = 1,
+            PageType = PageType.Data,
+            Checksum = UInt32.MaxValue,
+            DataLength = 128
+        };
+
+        var bytes = header.ToBytes();
+        var deserialized = PageHeader.FromBytes(bytes);
+
+        Assert.Equal(UInt32.MaxValue, deserialized.Checksum);
+    }
+
+    [Fact]
+    public void TestLargeDataLength()
+    {
+        var header = new PageHeader
+        {
+            PageId = 1,
+            PageType = PageType.Data,
+            DataLength = UInt32.MaxValue
+        };
+
+        var bytes = header.ToBytes();
+        var deserialized = PageHeader.FromBytes(bytes);
+
+        Assert.Equal(UInt32.MaxValue, deserialized.DataLength);
+    }
+
+    [Fact]
+    public void TestZeroValues()
+    {
+        var header = new PageHeader
+        {
+            PageId = 0,
+            PageType = PageType.Empty,
+            Lsn = 0,
+            Checksum = 0,
+            DataLength = 0
+        };
+
+        var bytes = header.ToBytes();
+        var deserialized = PageHeader.FromBytes(bytes);
+
+        Assert.Equal(0UL, deserialized.PageId);
+        Assert.Equal(PageType.Empty, deserialized.PageType);
+        Assert.Equal(0UL, deserialized.Lsn);
+        Assert.Equal(0u, deserialized.Checksum);
+        Assert.Equal(0u, deserialized.DataLength);
+    }
+
+    [Fact]
+    public void TestReservedBytesAreZero()
+    {
+        var header = new PageHeader
+        {
+            PageId = 123,
+            PageType = PageType.Directory,
+            Lsn = 456,
+            Checksum = 789,
+            DataLength = 1000
+        };
+
+        var bytes = header.ToBytes();
+
+        // Reserved 3 bytes at offset 9-11
+        Assert.Equal(0, bytes[9]);
+        Assert.Equal(0, bytes[10]);
+        Assert.Equal(0, bytes[11]);
+
+        // Reserved 4 bytes at offset 28-31
+        for (var i = 28; i < 32; i++)
+        {
+            Assert.Equal(0, bytes[i]);
+        }
+    }
+
+    [Fact]
+    public void TestRoundTripWithRandomData()
+    {
+        var random = new Random(42);
+
+        for (var i = 0; i < 100; i++)
+        {
+            var header = new PageHeader
+            {
+                PageId = (UInt64)random.Next() * (UInt64)random.Next(),
+                PageType = (PageType)(random.Next(0, 5)),
+                Lsn = (UInt64)random.Next() * (UInt64)random.Next(),
+                Checksum = (UInt32)random.Next(),
+                DataLength = (UInt32)random.Next()
+            };
+
+            var bytes = header.ToBytes();
+            var deserialized = PageHeader.FromBytes(bytes);
+
+            Assert.Equal(header.PageId, deserialized.PageId);
+            Assert.Equal(header.PageType, deserialized.PageType);
+            Assert.Equal(header.Lsn, deserialized.Lsn);
+            Assert.Equal(header.Checksum, deserialized.Checksum);
+            Assert.Equal(header.DataLength, deserialized.DataLength);
+        }
+    }
+
+    [Fact]
+    public void TestMetadataPageType()
+    {
+        var header = new PageHeader
+        {
+            PageId = 999,
+            PageType = PageType.Metadata,
+            Lsn = 777,
+            DataLength = 333
+        };
+
+        var bytes = header.ToBytes();
+        var deserialized = PageHeader.FromBytes(bytes);
+
+        Assert.Equal(PageType.Metadata, deserialized.PageType);
+    }
 }
Added +316 -0
diff --git a/XUnitTest/Storage/TableFileManagerTests.cs b/XUnitTest/Storage/TableFileManagerTests.cs
new file mode 100644
index 0000000..7259e0a
--- /dev/null
+++ b/XUnitTest/Storage/TableFileManagerTests.cs
@@ -0,0 +1,316 @@
+using System;
+using System.IO;
+using System.Linq;
+using NewLife.NovaDb.Core;
+using NewLife.NovaDb.Storage;
+using Xunit;
+
+namespace XUnitTest.Storage;
+
+public class TableFileManagerTests : IDisposable
+{
+    private readonly String _testPath;
+    private readonly DbOptions _options;
+
+    public TableFileManagerTests()
+    {
+        _testPath = Path.Combine(Path.GetTempPath(), $"NovaTest_{Guid.NewGuid()}");
+        _options = new DbOptions
+        {
+            Path = _testPath,
+            PageSize = 4096
+        };
+
+        Directory.CreateDirectory(_testPath);
+    }
+
+    public void Dispose()
+    {
+        if (Directory.Exists(_testPath))
+        {
+            Directory.Delete(_testPath, true);
+        }
+    }
+
+    #region 构造函数
+    [Fact]
+    public void TestConstructorNullDatabasePath()
+    {
+        Assert.Throws<ArgumentNullException>(() => new TableFileManager(null!, "Users", _options));
+    }
+
+    [Fact]
+    public void TestConstructorNullTableName()
+    {
+        Assert.Throws<ArgumentNullException>(() => new TableFileManager(_testPath, null!, _options));
+    }
+
+    [Fact]
+    public void TestConstructorEmptyTableName()
+    {
+        Assert.Throws<ArgumentException>(() => new TableFileManager(_testPath, "", _options));
+        Assert.Throws<ArgumentException>(() => new TableFileManager(_testPath, "  ", _options));
+    }
+
+    [Fact]
+    public void TestConstructorNullOptions()
+    {
+        Assert.Throws<ArgumentNullException>(() => new TableFileManager(_testPath, "Users", null!));
+    }
+
+    [Fact]
+    public void TestConstructorProperties()
+    {
+        var manager = new TableFileManager(_testPath, "Products", _options);
+        Assert.Equal(_testPath, manager.DatabasePath);
+        Assert.Equal("Products", manager.TableName);
+    }
+    #endregion
+
+    #region 路径生成
+    [Fact]
+    public void TestGetDataFilePath()
+    {
+        var manager = new TableFileManager(_testPath, "Users", _options);
+
+        var path1 = manager.GetDataFilePath();
+        Assert.Equal(Path.Combine(_testPath, "Users.data"), path1);
+
+        var path2 = manager.GetDataFilePath(0);
+        Assert.Equal(Path.Combine(_testPath, "Users_0.data"), path2);
+
+        var path3 = manager.GetDataFilePath(5);
+        Assert.Equal(Path.Combine(_testPath, "Users_5.data"), path3);
+    }
+
+    [Fact]
+    public void TestGetPrimaryIndexFilePath()
+    {
+        var manager = new TableFileManager(_testPath, "Orders", _options);
+
+        var path1 = manager.GetPrimaryIndexFilePath();
+        Assert.Equal(Path.Combine(_testPath, "Orders.idx"), path1);
+
+        var path2 = manager.GetPrimaryIndexFilePath(0);
+        Assert.Equal(Path.Combine(_testPath, "Orders_0.idx"), path2);
+    }
+
+    [Fact]
+    public void TestGetSecondaryIndexFilePath()
+    {
+        var manager = new TableFileManager(_testPath, "Products", _options);
+
+        var path1 = manager.GetSecondaryIndexFilePath("idx_name");
+        Assert.Equal(Path.Combine(_testPath, "Products_idx_name.idx"), path1);
+
+        var path2 = manager.GetSecondaryIndexFilePath("idx_category", 2);
+        Assert.Equal(Path.Combine(_testPath, "Products_2_idx_category.idx"), path2);
+    }
+
+    [Fact]
+    public void TestGetSecondaryIndexFilePathEmptyName()
+    {
+        var manager = new TableFileManager(_testPath, "Products", _options);
+        Assert.Throws<ArgumentException>(() => manager.GetSecondaryIndexFilePath(""));
+        Assert.Throws<ArgumentException>(() => manager.GetSecondaryIndexFilePath("  "));
+    }
+
+    [Fact]
+    public void TestGetWalFilePath()
+    {
+        var manager = new TableFileManager(_testPath, "Logs", _options);
+
+        var path1 = manager.GetWalFilePath();
+        Assert.Equal(Path.Combine(_testPath, "Logs.wal"), path1);
+
+        var path2 = manager.GetWalFilePath(1);
+        Assert.Equal(Path.Combine(_testPath, "Logs_1.wal"), path2);
+    }
+    #endregion
+
+    #region ListDataShards
+    [Fact]
+    public void TestListDataShards()
+    {
+        var manager = new TableFileManager(_testPath, "BigTable", _options);
+
+        File.WriteAllText(manager.GetDataFilePath(0), "");
+        File.WriteAllText(manager.GetDataFilePath(2), "");
+        File.WriteAllText(manager.GetDataFilePath(5), "");
+
+        var shards = manager.ListDataShards().ToList();
+
+        Assert.Equal(3, shards.Count);
+        Assert.Equal(0, shards[0]);
+        Assert.Equal(2, shards[1]);
+        Assert.Equal(5, shards[2]);
+    }
+
+    [Fact]
+    public void TestListDataShardsEmpty()
+    {
+        var manager = new TableFileManager(_testPath, "EmptyTable", _options);
+        var shards = manager.ListDataShards().ToList();
+        Assert.Empty(shards);
+    }
+
+    [Fact]
+    public void TestListDataShardsDirectoryNotExists()
+    {
+        var manager = new TableFileManager("/nonexistent/path", "Users", _options);
+        var shards = manager.ListDataShards().ToList();
+        Assert.Empty(shards);
+    }
+    #endregion
+
+    #region ListSecondaryIndexes
+    [Fact]
+    public void TestListSecondaryIndexes()
+    {
+        var manager = new TableFileManager(_testPath, "Users", _options);
+
+        // 主键索引(应被忽略,因为不含 _ 分隔的索引名)
+        File.WriteAllText(manager.GetPrimaryIndexFilePath(), "");
+
+        File.WriteAllText(manager.GetSecondaryIndexFilePath("idx_email"), "");
+        File.WriteAllText(manager.GetSecondaryIndexFilePath("idx_name"), "");
+        File.WriteAllText(manager.GetSecondaryIndexFilePath("idx_age", 1), "");
+
+        var indexes = manager.ListSecondaryIndexes().ToList();
+
+        Assert.Equal(3, indexes.Count);
+        Assert.Contains("idx_age", indexes);
+        Assert.Contains("idx_email", indexes);
+        Assert.Contains("idx_name", indexes);
+    }
+
+    [Fact]
+    public void TestListSecondaryIndexesEmpty()
+    {
+        var manager = new TableFileManager(_testPath, "NoIndex", _options);
+        var indexes = manager.ListSecondaryIndexes().ToList();
+        Assert.Empty(indexes);
+    }
+
+    [Fact]
+    public void TestListSecondaryIndexesDirectoryNotExists()
+    {
+        var manager = new TableFileManager("/nonexistent/path", "Users", _options);
+        var indexes = manager.ListSecondaryIndexes().ToList();
+        Assert.Empty(indexes);
+    }
+
+    [Fact]
+    public void TestListSecondaryIndexesDeduplication()
+    {
+        var manager = new TableFileManager(_testPath, "Multi", _options);
+
+        // 同一个索引在不同分片存在
+        File.WriteAllText(manager.GetSecondaryIndexFilePath("idx_status", 0), "");
+        File.WriteAllText(manager.GetSecondaryIndexFilePath("idx_status", 1), "");
+        File.WriteAllText(manager.GetSecondaryIndexFilePath("idx_status", 2), "");
+
+        var indexes = manager.ListSecondaryIndexes().ToList();
+        Assert.Single(indexes); // 去重后只有一个
+        Assert.Contains("idx_status", indexes);
+    }
+    #endregion
+
+    #region DeleteAllFiles
+    [Fact]
+    public void TestDeleteAllFiles()
+    {
+        var manager = new TableFileManager(_testPath, "TempTable", _options);
+
+        File.WriteAllText(manager.GetDataFilePath(), "");
+        File.WriteAllText(manager.GetDataFilePath(0), "");
+        File.WriteAllText(manager.GetPrimaryIndexFilePath(), "");
+        File.WriteAllText(manager.GetSecondaryIndexFilePath("idx_test"), "");
+        File.WriteAllText(manager.GetWalFilePath(), "");
+        File.WriteAllText(manager.GetWalFilePath(0), "");
+
+        manager.DeleteAllFiles();
+
+        Assert.False(File.Exists(manager.GetDataFilePath()));
+        Assert.False(File.Exists(manager.GetDataFilePath(0)));
+        Assert.False(File.Exists(manager.GetPrimaryIndexFilePath()));
+        Assert.False(File.Exists(manager.GetSecondaryIndexFilePath("idx_test")));
+        Assert.False(File.Exists(manager.GetWalFilePath()));
+        Assert.False(File.Exists(manager.GetWalFilePath(0)));
+    }
+
+    [Fact]
+    public void TestDeleteAllFilesNoFiles()
+    {
+        var manager = new TableFileManager(_testPath, "Empty", _options);
+        manager.DeleteAllFiles(); // 不应抛异常
+    }
+
+    [Fact]
+    public void TestDeleteAllFilesDirectoryNotExists()
+    {
+        var manager = new TableFileManager("/nonexistent/path", "Users", _options);
+        manager.DeleteAllFiles(); // 不应抛异常
+    }
+
+    [Fact]
+    public void TestDeleteAllFilesDoesNotAffectOtherTables()
+    {
+        var manager1 = new TableFileManager(_testPath, "TableA", _options);
+        var manager2 = new TableFileManager(_testPath, "TableB", _options);
+
+        File.WriteAllText(manager1.GetDataFilePath(), "");
+        File.WriteAllText(manager2.GetDataFilePath(), "");
+
+        manager1.DeleteAllFiles();
+
+        Assert.False(File.Exists(manager1.GetDataFilePath()));
+        Assert.True(File.Exists(manager2.GetDataFilePath())); // 另一张表不受影响
+    }
+    #endregion
+
+    #region Exists
+    [Fact]
+    public void TestExists()
+    {
+        var manager = new TableFileManager(_testPath, "CheckTable", _options);
+
+        Assert.False(manager.Exists());
+
+        File.WriteAllText(manager.GetDataFilePath(), "");
+
+        Assert.True(manager.Exists());
+    }
+
+    [Fact]
+    public void TestExistsDirectoryNotExists()
+    {
+        var manager = new TableFileManager("/nonexistent/path", "Users", _options);
+        Assert.False(manager.Exists());
+    }
+    #endregion
+
+    #region 分片文件综合
+    [Fact]
+    public void TestShardedFileNaming()
+    {
+        var manager = new TableFileManager(_testPath, "ShardedTable", _options);
+
+        File.WriteAllText(manager.GetDataFilePath(0), "");
+        File.WriteAllText(manager.GetDataFilePath(1), "");
+        File.WriteAllText(manager.GetPrimaryIndexFilePath(0), "");
+        File.WriteAllText(manager.GetSecondaryIndexFilePath("idx_status", 0), "");
+        File.WriteAllText(manager.GetWalFilePath(0), "");
+
+        var shards = manager.ListDataShards().ToList();
+        var indexes = manager.ListSecondaryIndexes().ToList();
+
+        Assert.Equal(2, shards.Count);
+        Assert.Contains(0, shards);
+        Assert.Contains(1, shards);
+
+        Assert.Single(indexes);
+        Assert.Contains("idx_status", indexes);
+    }
+    #endregion
+}