|
| 1 | +import Foundation |
| 2 | +import Testing |
| 3 | + |
| 4 | +import TextBundle |
| 5 | + |
| 6 | +/// Regression tests for the nullability hardening in `TextBundleWrapper`: |
| 7 | +/// a read failure must surface as a thrown error (the initializer is now `nullable`, |
| 8 | +/// so it bridges to a throwing Swift initializer) rather than a nil-holding instance, |
| 9 | +/// and a text file that isn't valid UTF-8 must fail the read instead of leaving |
| 10 | +/// the `nonnull` `text` property nil. |
| 11 | +struct TextBundleWrapperTests { |
| 12 | + |
| 13 | + // MARK: Helpers |
| 14 | + |
| 15 | + /// Writes a `.textbundle` directory to a unique temporary location and returns its URL. |
| 16 | + /// Pass `nil` for `info` or `textFileName`/`textData` to omit that member. |
| 17 | + private func makeBundle(info: Data?, textFileName: String?, textData: Data?) throws -> URL { |
| 18 | + let root = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) |
| 19 | + .appendingPathComponent("TextBundleTests-\(UUID().uuidString)", isDirectory: true) |
| 20 | + let bundle = root.appendingPathComponent("document.textbundle", isDirectory: true) |
| 21 | + try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) |
| 22 | + if let info { |
| 23 | + try info.write(to: bundle.appendingPathComponent("info.json")) |
| 24 | + } |
| 25 | + if let textFileName, let textData { |
| 26 | + try textData.write(to: bundle.appendingPathComponent(textFileName)) |
| 27 | + } |
| 28 | + return bundle |
| 29 | + } |
| 30 | + |
| 31 | + private var validInfoJSON: Data { |
| 32 | + Data(#"{"version":2,"type":"net.daringfireball.markdown"}"#.utf8) |
| 33 | + } |
| 34 | + |
| 35 | + // MARK: Happy path |
| 36 | + |
| 37 | + @Test func validBundleLoadsText() throws { |
| 38 | + let url = try makeBundle(info: validInfoJSON, textFileName: "text.markdown", textData: Data("# Hello".utf8)) |
| 39 | + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } |
| 40 | + |
| 41 | + let wrapper = try TextBundleWrapper(contentsOf: url, options: .immediate) |
| 42 | + #expect(wrapper.text == "# Hello") |
| 43 | + #expect(wrapper.type == kUTTypeMarkdown) |
| 44 | + } |
| 45 | + |
| 46 | + // MARK: Finding 1 — read failures must throw (nullable initializer) |
| 47 | + |
| 48 | + @Test func missingTextFileThrows() throws { |
| 49 | + let url = try makeBundle(info: validInfoJSON, textFileName: nil, textData: nil) |
| 50 | + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } |
| 51 | + |
| 52 | + #expect(throws: (any Error).self) { |
| 53 | + _ = try TextBundleWrapper(contentsOf: url, options: .immediate) |
| 54 | + } |
| 55 | + } |
| 56 | + |
| 57 | + @Test func missingInfoJSONThrows() throws { |
| 58 | + let url = try makeBundle(info: nil, textFileName: "text.markdown", textData: Data("hi".utf8)) |
| 59 | + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } |
| 60 | + |
| 61 | + #expect(throws: (any Error).self) { |
| 62 | + _ = try TextBundleWrapper(contentsOf: url, options: .immediate) |
| 63 | + } |
| 64 | + } |
| 65 | + |
| 66 | + @Test func unreadableURLThrows() { |
| 67 | + let url = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) |
| 68 | + .appendingPathComponent("does-not-exist-\(UUID().uuidString).textbundle", isDirectory: true) |
| 69 | + |
| 70 | + #expect(throws: (any Error).self) { |
| 71 | + _ = try TextBundleWrapper(contentsOf: url, options: .immediate) |
| 72 | + } |
| 73 | + } |
| 74 | + |
| 75 | + // MARK: Finding 2 — non-UTF-8 text must fail the read, not leave `text` nil |
| 76 | + |
| 77 | + @Test func nonUTF8TextThrows() throws { |
| 78 | + // 0xFF is never a valid UTF-8 byte, so NSString decoding returns nil. |
| 79 | + let url = try makeBundle(info: validInfoJSON, textFileName: "text.markdown", textData: Data([0xFF, 0xFE, 0xFF])) |
| 80 | + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } |
| 81 | + |
| 82 | + #expect(throws: (any Error).self) { |
| 83 | + _ = try TextBundleWrapper(contentsOf: url, options: .immediate) |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + // MARK: Assets |
| 88 | + |
| 89 | + @Test func assetsAreLoadedAndLookedUpByFilename() throws { |
| 90 | + let url = try makeBundle(info: validInfoJSON, textFileName: "text.markdown", textData: Data("# Hi".utf8)) |
| 91 | + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } |
| 92 | + let assets = url.appendingPathComponent("assets", isDirectory: true) |
| 93 | + try FileManager.default.createDirectory(at: assets, withIntermediateDirectories: true) |
| 94 | + try Data([0x89, 0x50, 0x4E, 0x47]).write(to: assets.appendingPathComponent("foo.png")) |
| 95 | + |
| 96 | + let wrapper = try TextBundleWrapper(contentsOf: url, options: .immediate) |
| 97 | + #expect(wrapper.assetsFileWrapper.fileWrappers?["foo.png"] != nil) |
| 98 | + #expect(wrapper.fileWrapper(forAssetFilename: "foo.png") != nil) |
| 99 | + #expect(wrapper.fileWrapper(forAssetFilename: "missing.png") == nil) |
| 100 | + } |
| 101 | + |
| 102 | + // MARK: Metadata |
| 103 | + |
| 104 | + @Test func nonMarkdownTypeIsReported() throws { |
| 105 | + let info = Data(#"{"version":2,"type":"public.plain-text"}"#.utf8) |
| 106 | + let url = try makeBundle(info: info, textFileName: "text.txt", textData: Data("hi".utf8)) |
| 107 | + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } |
| 108 | + |
| 109 | + let wrapper = try TextBundleWrapper(contentsOf: url, options: .immediate) |
| 110 | + #expect(wrapper.type == "public.plain-text") |
| 111 | + #expect(wrapper.type != kUTTypeMarkdown) |
| 112 | + } |
| 113 | + |
| 114 | + @Test func invalidInfoJSONThrows() throws { |
| 115 | + let url = try makeBundle( |
| 116 | + info: Data("{ not valid json".utf8), |
| 117 | + textFileName: "text.markdown", |
| 118 | + textData: Data("hi".utf8) |
| 119 | + ) |
| 120 | + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } |
| 121 | + |
| 122 | + #expect(throws: (any Error).self) { |
| 123 | + _ = try TextBundleWrapper(contentsOf: url, options: .immediate) |
| 124 | + } |
| 125 | + } |
| 126 | + |
| 127 | + @Test func nonDictionaryInfoJSONThrows() throws { |
| 128 | + // Valid JSON but a top-level array (not an object): must fail the read |
| 129 | + // instead of crashing on -[NSArray objectForKeyedSubscript:]. |
| 130 | + let url = try makeBundle(info: Data("[1,2,3]".utf8), textFileName: "text.markdown", textData: Data("hi".utf8)) |
| 131 | + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } |
| 132 | + |
| 133 | + #expect(throws: (any Error).self) { |
| 134 | + _ = try TextBundleWrapper(contentsOf: url, options: .immediate) |
| 135 | + } |
| 136 | + } |
| 137 | +} |
0 commit comments