Files
damus/damusTests/StorageStatsManagerTests.swift
Daniel D’Aquino 795fce1b65 Add storage usage stats settings view
This commit implements a new Storage settings view that displays storage
usage statistics for NostrDB, snapshot database, and Kingfisher image cache.

Key features:
- Interactive pie chart visualization (iOS 17+) with tap-to-select functionality
- Pull-to-refresh gesture to recalculate storage
- Categorized list showing each storage type with size and percentage
- Total storage sum displayed at bottom
- Conditional compilation for iOS 16/17+ compatibility
- All calculations run on background thread to avoid blocking main thread
- NostrDB storage breakdown

Changelog-Added: Storage usage statistics view in Settings
Changelog-Changed: Moved clear cache button to storage settings
Closes: https://github.com/damus-io/damus/issues/3649
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-25 15:45:37 -08:00

538 lines
22 KiB
Swift

//
// StorageStatsManagerTests.swift
// damusTests
//
// Created by OpenCode on 2026-02-25.
//
import XCTest
@testable import damus
import Kingfisher
/// Comprehensive test suite for storage usage calculation logic
///
/// Tests cover:
/// - StorageStats calculations (total size, percentages)
/// - File size calculations with temporary test files
/// - Async storage stats calculations
/// - Byte formatting utilities
/// - Ndb.getStats() database statistics
/// - Integration between components
/// - Thread safety and error handling
final class StorageStatsManagerTests: XCTestCase {
var tempDirectory: URL!
var mockNostrDBPath: String!
var mockSnapshotPath: String!
override func setUp() {
super.setUp()
// Create temporary directory for test files
tempDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent("StorageStatsManagerTests-\(UUID().uuidString)")
try? FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
// Create mock database directories
let nostrDBDir = tempDirectory.appendingPathComponent("nostrdb")
let snapshotDir = tempDirectory.appendingPathComponent("snapshot")
try? FileManager.default.createDirectory(at: nostrDBDir, withIntermediateDirectories: true)
try? FileManager.default.createDirectory(at: snapshotDir, withIntermediateDirectories: true)
mockNostrDBPath = nostrDBDir.path
mockSnapshotPath = snapshotDir.path
}
override func tearDown() {
// Clean up temporary files
if let tempDirectory = tempDirectory {
try? FileManager.default.removeItem(at: tempDirectory)
}
tempDirectory = nil
mockNostrDBPath = nil
mockSnapshotPath = nil
super.tearDown()
}
// MARK: - Helper Methods
/// Create a temporary file with specified size
/// - Parameters:
/// - path: Full path for the file
/// - size: Size in bytes
private func createTestFile(at path: String, size: UInt64) throws {
let data = Data(repeating: 0, count: Int(size))
try data.write(to: URL(fileURLWithPath: path))
}
/// Get file size using FileManager (reference implementation)
private func getActualFileSize(at path: String) -> UInt64? {
guard FileManager.default.fileExists(atPath: path) else { return nil }
do {
let attributes = try FileManager.default.attributesOfItem(atPath: path)
return attributes[.size] as? UInt64
} catch {
return nil
}
}
// MARK: - 1. StorageStats Structure Tests
/// Test that totalSize correctly sums all storage components
func testTotalSizeCalculation() {
let stats = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 1000,
snapshotSize: 500,
imageCacheSize: 250
)
XCTAssertEqual(stats.totalSize, 1750, "Total size should sum all components")
}
/// Test percentage calculation accuracy
func testPercentageCalculation() {
let stats = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 600,
snapshotSize: 300,
imageCacheSize: 100
)
// Total = 1000, so 600 should be 60%
let nostrdbPercentage = stats.percentage(for: 600)
XCTAssertEqual(nostrdbPercentage, 60.0, accuracy: 0.01, "NostrDB should be 60% of total")
let snapshotPercentage = stats.percentage(for: 300)
XCTAssertEqual(snapshotPercentage, 30.0, accuracy: 0.01, "Snapshot should be 30% of total")
let cachePercentage = stats.percentage(for: 100)
XCTAssertEqual(cachePercentage, 10.0, accuracy: 0.01, "Cache should be 10% of total")
}
/// Test percentage calculation when total is zero (edge case)
func testPercentageWithZeroTotal() {
let stats = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 0,
snapshotSize: 0,
imageCacheSize: 0
)
let percentage = stats.percentage(for: 100)
XCTAssertEqual(percentage, 0.0, "Percentage should be 0 when total is 0")
}
/// Test that StorageStats conforms to Hashable properly
func testStorageStatsHashableConformance() {
let stats1 = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 1000,
snapshotSize: 500,
imageCacheSize: 250
)
let stats2 = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 1000,
snapshotSize: 500,
imageCacheSize: 250
)
let stats3 = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 2000,
snapshotSize: 500,
imageCacheSize: 250
)
// Equal stats should be equal and have same hash
XCTAssertEqual(stats1, stats2, "Identical stats should be equal")
XCTAssertEqual(stats1.hashValue, stats2.hashValue, "Equal stats should have same hash")
// Different stats should not be equal
XCTAssertNotEqual(stats1, stats3, "Different stats should not be equal")
// Should work in Set
let set: Set<StorageStats> = [stats1, stats2, stats3]
XCTAssertEqual(set.count, 2, "Set should contain 2 unique stats")
}
// MARK: - 2. File Size Calculation Tests
/// Test file size calculation with an existing file
func testGetFileSizeWithExistingFile() throws {
let testFilePath = tempDirectory.appendingPathComponent("test-file.dat").path
let expectedSize: UInt64 = 1024 * 1024 // 1 MB
// Create test file with known size
try createTestFile(at: testFilePath, size: expectedSize)
// Verify file was created correctly
let actualSize = getActualFileSize(at: testFilePath)
XCTAssertNotNil(actualSize, "Test file should exist")
XCTAssertEqual(actualSize, expectedSize, "Test file should have expected size")
}
/// Test file size calculation when file doesn't exist (should return 0)
func testGetFileSizeWithNonexistentFile() {
let nonexistentPath = tempDirectory.appendingPathComponent("nonexistent.dat").path
// Verify file doesn't exist
XCTAssertFalse(FileManager.default.fileExists(atPath: nonexistentPath), "File should not exist")
let size = getActualFileSize(at: nonexistentPath)
XCTAssertNil(size, "Size should be nil for nonexistent file")
}
/// Test NostrDB file size calculation with valid path
func testGetNostrDBSizeWithValidPath() throws {
let dbFilePath = "\(mockNostrDBPath!)/\(Ndb.main_db_file_name)"
let expectedSize: UInt64 = 5 * 1024 * 1024 // 5 MB
// Create mock database file
try createTestFile(at: dbFilePath, size: expectedSize)
// Verify file size can be retrieved
let actualSize = getActualFileSize(at: dbFilePath)
XCTAssertNotNil(actualSize, "DB file should exist")
XCTAssertEqual(actualSize, expectedSize, "DB file should have expected size")
}
/// Test snapshot database file size calculation with valid path
func testGetSnapshotDBSizeWithValidPath() throws {
let dbFilePath = "\(mockSnapshotPath!)/\(Ndb.main_db_file_name)"
let expectedSize: UInt64 = 2 * 1024 * 1024 // 2 MB
// Create mock snapshot database file
try createTestFile(at: dbFilePath, size: expectedSize)
// Verify file size can be retrieved
let actualSize = getActualFileSize(at: dbFilePath)
XCTAssertNotNil(actualSize, "Snapshot DB file should exist")
XCTAssertEqual(actualSize, expectedSize, "Snapshot DB file should have expected size")
}
// MARK: - 3. Byte Formatting Tests
/// Test formatting of zero bytes
func testFormatBytesZero() {
let formatted = StorageStatsManager.formatBytes(0)
// ByteCountFormatter may format as "Zero bytes", "0 bytes", "0 KB", etc.
// We just verify it's a valid non-empty string
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
// Most common formats include "0" or "Zero"
let containsZero = formatted.contains("0") || formatted.uppercased().contains("ZERO")
XCTAssertTrue(containsZero, "Zero bytes should contain '0' or 'Zero', got: \(formatted)")
}
/// Test formatting of small byte values (< 1 KB)
func testFormatBytesSmall() {
let formatted = StorageStatsManager.formatBytes(512)
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
// Should contain a numeric value
XCTAssertTrue(formatted.contains("512") || formatted.contains("0.5"), "Should contain size value")
}
/// Test formatting of kilobyte values
func testFormatBytesKilobytes() {
let oneKB: UInt64 = 1024
let formatted = StorageStatsManager.formatBytes(oneKB * 5) // 5 KB
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
// Should mention KB or kilobytes
XCTAssertTrue(formatted.uppercased().contains("KB") || formatted.uppercased().contains("K"),
"Should indicate kilobytes: \(formatted)")
}
/// Test formatting of megabyte values
func testFormatBytesMegabytes() {
let oneMB: UInt64 = 1024 * 1024
let formatted = StorageStatsManager.formatBytes(oneMB * 10) // 10 MB
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
// Should mention MB or megabytes
XCTAssertTrue(formatted.uppercased().contains("MB") || formatted.uppercased().contains("M"),
"Should indicate megabytes: \(formatted)")
}
/// Test formatting of gigabyte values
func testFormatBytesGigabytes() {
let oneGB: UInt64 = 1024 * 1024 * 1024
let formatted = StorageStatsManager.formatBytes(oneGB * 2) // 2 GB
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
// Should mention GB or gigabytes
XCTAssertTrue(formatted.uppercased().contains("GB") || formatted.uppercased().contains("G"),
"Should indicate gigabytes: \(formatted)")
}
/// Test formatting of very large values
func testFormatBytesLarge() {
let oneTB: UInt64 = 1024 * 1024 * 1024 * 1024
let formatted = StorageStatsManager.formatBytes(oneTB)
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
// Should handle terabyte values gracefully
XCTAssertTrue(formatted.uppercased().contains("TB") || formatted.uppercased().contains("T") ||
formatted.uppercased().contains("GB") || formatted.uppercased().contains("G"),
"Should format large values: \(formatted)")
}
// MARK: - 4. Async Storage Stats Calculation Tests
/// Test storage stats calculation without Ndb instance
func testCalculateStorageStatsWithoutNdb() async throws {
// Note: This test verifies the calculation succeeds and returns valid stats
// We don't check exact values since they depend on actual system state
let stats = try await StorageStatsManager.shared.calculateStorageStats(ndb: nil)
// Verify stats structure is valid
XCTAssertNotNil(stats, "Stats should not be nil")
XCTAssertNil(stats.nostrdbDetails, "Details should be nil when no Ndb provided")
// All sizes should be non-negative
XCTAssertGreaterThanOrEqual(stats.nostrdbSize, 0, "NostrDB size should be non-negative")
XCTAssertGreaterThanOrEqual(stats.snapshotSize, 0, "Snapshot size should be non-negative")
XCTAssertGreaterThanOrEqual(stats.imageCacheSize, 0, "Image cache size should be non-negative")
// Total should equal sum
let expectedTotal = stats.nostrdbSize + stats.snapshotSize + stats.imageCacheSize
XCTAssertEqual(stats.totalSize, expectedTotal, "Total should equal sum of components")
}
// MARK: - 5. NdbDatabaseStats Tests
/// Test NdbDatabaseStats total size calculation
func testNdbDatabaseStatsCalculations() {
let dbStats = NdbDatabaseStats(
database: .note,
keySize: 1000,
valueSize: 5000
)
XCTAssertEqual(dbStats.totalSize, 6000, "Total should be key + value size")
XCTAssertEqual(dbStats.database, .note, "Database type should be preserved")
XCTAssertEqual(dbStats.keySize, 1000, "Key size should be preserved")
XCTAssertEqual(dbStats.valueSize, 5000, "Value size should be preserved")
}
/// Test NdbStats total size calculation
func testNdbStatsTotalCalculation() {
let stats = NdbStats(databaseStats: [
NdbDatabaseStats(database: .note, keySize: 1000, valueSize: 5000),
NdbDatabaseStats(database: .profile, keySize: 500, valueSize: 2000),
NdbDatabaseStats(database: .noteId, keySize: 200, valueSize: 800)
])
// Total should be sum of all database totals
// (1000+5000) + (500+2000) + (200+800) = 9500
XCTAssertEqual(stats.totalSize, 9500, "Total should sum all database sizes")
}
/// Test NdbStats with empty database list
func testNdbStatsEmpty() {
let stats = NdbStats(databaseStats: [])
XCTAssertEqual(stats.totalSize, 0, "Empty stats should have zero total")
XCTAssertTrue(stats.databaseStats.isEmpty, "Database stats should be empty")
}
/// Test NdbDatabaseStats hashable conformance
func testNdbDatabaseStatsHashableConformance() {
let stats1 = NdbDatabaseStats(database: .note, keySize: 1000, valueSize: 5000)
let stats2 = NdbDatabaseStats(database: .note, keySize: 1000, valueSize: 5000)
let stats3 = NdbDatabaseStats(database: .profile, keySize: 1000, valueSize: 5000)
XCTAssertEqual(stats1, stats2, "Identical stats should be equal")
XCTAssertNotEqual(stats1, stats3, "Different database type should not be equal")
// Should work in Set
let set: Set<NdbDatabaseStats> = [stats1, stats2, stats3]
XCTAssertEqual(set.count, 2, "Set should contain 2 unique stats")
}
/// Test NdbStats hashable conformance
func testNdbStatsHashableConformance() {
let dbStats1 = NdbDatabaseStats(database: .note, keySize: 1000, valueSize: 5000)
let dbStats2 = NdbDatabaseStats(database: .profile, keySize: 500, valueSize: 2000)
let stats1 = NdbStats(databaseStats: [dbStats1, dbStats2])
let stats2 = NdbStats(databaseStats: [dbStats1, dbStats2])
let stats3 = NdbStats(databaseStats: [dbStats1])
XCTAssertEqual(stats1, stats2, "Identical stats should be equal")
XCTAssertNotEqual(stats1, stats3, "Different database count should not be equal")
// Should work in Set
let set: Set<NdbStats> = [stats1, stats2, stats3]
XCTAssertEqual(set.count, 2, "Set should contain 2 unique stats")
}
// MARK: - 6. NdbDatabase Enum Tests
/// Test NdbDatabase display names
func testNdbDatabaseDisplayNames() {
// Display names include the C enum names in parentheses
XCTAssertEqual(NdbDatabase.note.displayName, "Notes (NDB_DB_NOTE)", "Note database display name")
XCTAssertEqual(NdbDatabase.profile.displayName, "Profiles (NDB_DB_PROFILE)", "Profile database display name")
XCTAssertEqual(NdbDatabase.noteBlocks.displayName, "Note Blocks", "Note blocks display name")
XCTAssertEqual(NdbDatabase.noteId.displayName, "Note ID Index", "Note ID index display name")
XCTAssertEqual(NdbDatabase.meta.displayName, "Metadata (NDB_DB_META)", "Metadata display name")
XCTAssertEqual(NdbDatabase.other.displayName, "Other Data", "Other data display name")
}
/// Test NdbDatabase icons
func testNdbDatabaseIcons() {
// Verify each database has an icon (non-empty string)
XCTAssertFalse(NdbDatabase.note.icon.isEmpty, "Note should have icon")
XCTAssertFalse(NdbDatabase.profile.icon.isEmpty, "Profile should have icon")
XCTAssertFalse(NdbDatabase.noteBlocks.icon.isEmpty, "Note blocks should have icon")
XCTAssertFalse(NdbDatabase.other.icon.isEmpty, "Other should have icon")
}
/// Test NdbDatabase colors
func testNdbDatabaseColors() {
// Verify each database has a color assigned
// We can't easily compare Color values, but we can verify they return Color instances
_ = NdbDatabase.note.color
_ = NdbDatabase.profile.color
_ = NdbDatabase.noteBlocks.color
_ = NdbDatabase.other.color
// If we get here without crashes, colors are working
XCTAssertTrue(true, "All database colors should be accessible")
}
/// Test NdbDatabase initialization from index
func testNdbDatabaseFromIndex() {
// Test valid indices
let db0 = NdbDatabase(fromIndex: 0)
XCTAssertNotEqual(db0, .other, "Index 0 should map to a valid database")
let db1 = NdbDatabase(fromIndex: 1)
XCTAssertNotEqual(db1, .other, "Index 1 should map to a valid database")
// Test invalid index (should default to .other)
let dbInvalid = NdbDatabase(fromIndex: 9999)
XCTAssertEqual(dbInvalid, .other, "Invalid index should default to .other")
}
// MARK: - 7. Integration Tests
/// Test complete storage stats flow with real-ish data
func testStorageStatsIntegrationFlow() async throws {
// This test verifies the entire flow works end-to-end
// We use actual calculation but don't assert specific values
let stats = try await StorageStatsManager.shared.calculateStorageStats(ndb: nil)
// Verify structure
XCTAssertNotNil(stats, "Stats should be calculated")
// Verify all components are accessible
let _ = stats.nostrdbSize
let _ = stats.snapshotSize
let _ = stats.imageCacheSize
let _ = stats.totalSize
// Verify percentage calculation works
if stats.totalSize > 0 {
let percentage = stats.percentage(for: stats.nostrdbSize)
XCTAssertGreaterThanOrEqual(percentage, 0.0, "Percentage should be non-negative")
XCTAssertLessThanOrEqual(percentage, 100.0, "Percentage should not exceed 100%")
}
// Verify formatting works
let formatted = StorageStatsManager.formatBytes(stats.totalSize)
XCTAssertFalse(formatted.isEmpty, "Formatted size should not be empty")
}
/// Test concurrent stats calculations (thread safety)
func testConcurrentStatsCalculations() async throws {
let iterations = 5
// Launch multiple concurrent calculations
try await withThrowingTaskGroup(of: StorageStats.self) { group in
for _ in 0..<iterations {
group.addTask {
return try await StorageStatsManager.shared.calculateStorageStats(ndb: nil)
}
}
var results: [StorageStats] = []
for try await stats in group {
results.append(stats)
}
XCTAssertEqual(results.count, iterations, "Should complete all calculations")
// All results should have valid structure
for stats in results {
XCTAssertGreaterThanOrEqual(stats.nostrdbSize, 0, "NostrDB size should be valid")
XCTAssertGreaterThanOrEqual(stats.snapshotSize, 0, "Snapshot size should be valid")
XCTAssertGreaterThanOrEqual(stats.imageCacheSize, 0, "Cache size should be valid")
}
}
}
/// Test storage stats with extreme UInt64 values, including sum at UInt64 boundary (no overflow)
func testStorageStatsExtremeValues() {
// Case: Sum at UInt64 boundary (no overflow)
// UInt64.max - 2 + 1 + 1 == UInt64.max
let maxStats = StorageStats(
nostrdbDetails: nil,
nostrdbSize: UInt64.max - 2,
snapshotSize: 1,
imageCacheSize: 1
)
// Verify correct summation at UInt64 boundary
XCTAssertEqual(maxStats.totalSize, UInt64.max, "Total should be exactly UInt64.max at boundary; no overflow should occur")
// Verify percentage calculation for each component
XCTAssertEqual(maxStats.percentage(for: UInt64.max - 2), (Double(UInt64.max - 2) / Double(UInt64.max)) * 100.0, accuracy: 0.0001)
XCTAssertEqual(maxStats.percentage(for: 1), (1.0 / Double(UInt64.max)) * 100.0, accuracy: 0.0001)
// All zeros case (already tested elsewhere, but included for completeness)
let zeroStats = StorageStats(
nostrdbDetails: nil,
nostrdbSize: 0,
snapshotSize: 0,
imageCacheSize: 0
)
XCTAssertEqual(zeroStats.totalSize, 0, "Zero stats should have zero total")
XCTAssertEqual(zeroStats.percentage(for: 0), 0.0, "Zero percentage for zero total")
// If overflow handling should be explicitly tested, add a comment. With current implementation, overflow cannot occur for UInt64 sums with three terms.
// If more than three terms or arbitrary user input are ever summed, consider adding explicit overflow guards.
}
/// Test byte formatter with various edge cases
func testFormatBytesEdgeCases() {
// Powers of 1024
let formatted1K = StorageStatsManager.formatBytes(1024)
XCTAssertFalse(formatted1K.isEmpty, "Should format 1KB")
let formatted1M = StorageStatsManager.formatBytes(1024 * 1024)
XCTAssertFalse(formatted1M.isEmpty, "Should format 1MB")
let formatted1G = StorageStatsManager.formatBytes(1024 * 1024 * 1024)
XCTAssertFalse(formatted1G.isEmpty, "Should format 1GB")
// Odd values
let formatted999 = StorageStatsManager.formatBytes(999)
XCTAssertFalse(formatted999.isEmpty, "Should format 999 bytes")
let formatted1023 = StorageStatsManager.formatBytes(1023)
XCTAssertFalse(formatted1023.isEmpty, "Should format 1023 bytes")
}
}