diff options
40 files changed, 1427 insertions, 414 deletions
diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6ca3848 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,94 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Common Commands + +### Development +- **Build**: `make build` or open the project in Xcode and press the run button +- **Format code**: `make format` +- **Lint code**: `make lint` +- **Run tests**: `make test` or use Xcode's test navigator or ⌘+U + +### Distribution +- **Create full distribution**: `make distribute` +- **Archive for distribution**: `make archive` +- **Package app**: `make package` + +## Architecture Overview + +### Core Structure +This is a SwiftUI-based macOS application for creating Wardley Maps using a text-based domain-specific language. + +**Key Components:** +- **MapDocument**: Document-based app architecture using SwiftUI's `FileDocument` protocol +- **MapParser**: Text-to-diagram parsing engine with strategy pattern +- **MapEditor**: Main editing interface with split view (text editor + visual map) +- **MapRenderView**: SwiftUI view that renders the visual map + +### Data Flow +1. User types text in `MapTextEditor` +2. `MapParser` parses text using parser strategies +3. Parsed data creates `ParsedMap` with vertices, edges, notes, etc. +4. `MapRenderView` renders the visual representation +5. Changes are automatically saved via document architecture + +### Parser Architecture +The parser uses a strategy pattern with these components: +- `MapParserStrategy` protocol for parsing different element types +- Individual strategies: `VertexParserStrategy`, `EdgeParserStrategy`, `NoteParserStrategy`, etc. +- `MapBuilder` accumulates parsed elements into final `ParsedMap` + +### Key Data Models +- **Vertex**: Map nodes with position and label +- **MapEdge**: Connections between vertices (with/without arrowheads) +- **Note**: Text annotations at specific positions +- **Blocker**: Obstacles placed in front of vertices +- **Opportunity**: Evolution arrows showing movement +- **Stage**: Different evolution frameworks (general, practice, data, etc.) + +### UI Architecture +- **MapEditor**: Main container with adaptive layout (horizontal/vertical split) +- **MapTextEditor**: Text editing with syntax highlighting and search +- **MapRenderView**: Visual map rendering with zoom and pan +- **EvolutionPicker**: Dropdown for selecting evolution framework +- **SearchBar**: Text search functionality with highlight navigation + +## Map Language Reference + +The app uses a domain-specific language for creating Wardley Maps: + +### Basic Elements +- **Nodes**: `Name (x,y)` where x,y are 0-100 coordinates +- **Edges**: `Node -- Node` (line) or `Node -> Node` (arrow) +- **Blockers**: `[Blocker] Node` +- **Evolution**: `[Evolution] Node +/-x` +- **Notes**: `[Note] (x,y) Text` +- **Groups**: `[Group] NodeA, NodeB, NodeC` +- **Axes**: `[I] x`, `[II] x`, `[III] x` for custom stage boundaries + +### File Format +- Files use `.wmap` extension with UTI `systems.tranquil.map.wmap` +- Plain text format parsed line by line +- Each line can contain one map element + +## Development Notes + +### Testing +- Unit tests are in `MapTests/MapTests.swift` +- UI tests are in `MapUITests/` +- Run tests with ⌘+U in Xcode + +### Auto-Updates +- Uses Sparkle framework for automatic updates +- Configuration in `Info.plist` and update commands + +### Code Style +- Uses swift-format for consistent formatting +- Run `make format` before committing +- All Swift files include GPL license header + +### Key Dependencies +- SwiftUI for UI framework +- Sparkle for auto-updates +- CoreGraphics for rendering calculations
\ No newline at end of file @@ -6,6 +6,12 @@ sparkle_account := tranquil.systems distribute: archive package generate_appcast +build: + @xcodebuild build -scheme Map + +test: + @xcodebuild test -scheme Map + package: @scripts/package.sh "$(project_name)" "$(build_directory)" @@ -24,4 +30,4 @@ format: lint: swift format lint -r . -.PHONY: package prepare archive generate_appcast package distribute format lint +.PHONY: package prepare archive generate_appcast package distribute format lint build test diff --git a/Map.xcodeproj/project.pbxproj b/Map.xcodeproj/project.pbxproj index fc3a574..aaa6948 100644 --- a/Map.xcodeproj/project.pbxproj +++ b/Map.xcodeproj/project.pbxproj @@ -3,53 +3,13 @@ archiveVersion = 1; classes = { }; - objectVersion = 60; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ - B5012E3F2C96232A00AC4D68 /* EvolutionPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E3E2C96232300AC4D68 /* EvolutionPicker.swift */; }; - B5012E422C96235E00AC4D68 /* Stage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E412C96235B00AC4D68 /* Stage.swift */; }; - B5012E452C9623C700AC4D68 /* Font+theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E442C9623C500AC4D68 /* Font+theme.swift */; }; - B5012E472C96243C00AC4D68 /* MapTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E462C96243500AC4D68 /* MapTextEditor.swift */; }; - B5012E492C96245B00AC4D68 /* Color+theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E482C96245800AC4D68 /* Color+theme.swift */; }; - B5012E4B2C96246F00AC4D68 /* NSColor+theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E4A2C96246D00AC4D68 /* NSColor+theme.swift */; }; - B5012E572C96249400AC4D68 /* NoteParserStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E4D2C96249400AC4D68 /* NoteParserStrategy.swift */; }; - B5012E582C96249400AC4D68 /* BlockerParserStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E4F2C96249400AC4D68 /* BlockerParserStrategy.swift */; }; - B5012E592C96249400AC4D68 /* VertexParserStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E4C2C96249400AC4D68 /* VertexParserStrategy.swift */; }; - B5012E5A2C96249400AC4D68 /* MapParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E532C96249400AC4D68 /* MapParser.swift */; }; - B5012E5B2C96249400AC4D68 /* OpportunityParserStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E502C96249400AC4D68 /* OpportunityParserStrategy.swift */; }; - B5012E5C2C96249400AC4D68 /* EdgeParserStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E4E2C96249400AC4D68 /* EdgeParserStrategy.swift */; }; - B5012E5D2C96249400AC4D68 /* StageParserStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E512C96249400AC4D68 /* StageParserStrategy.swift */; }; - B5012E5E2C96249400AC4D68 /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E552C96249400AC4D68 /* Debouncer.swift */; }; - B5012E622C96254700AC4D68 /* MapRenderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E5F2C96254700AC4D68 /* MapRenderView.swift */; }; - B5012E6B2C96255A00AC4D68 /* MapAxes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E632C96255A00AC4D68 /* MapAxes.swift */; }; - B5012E6C2C96255A00AC4D68 /* MapVertices.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E652C96255A00AC4D68 /* MapVertices.swift */; }; - B5012E6D2C96255A00AC4D68 /* MapStages.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E642C96255A00AC4D68 /* MapStages.swift */; }; - B5012E6E2C96255A00AC4D68 /* MapNotes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E662C96255A00AC4D68 /* MapNotes.swift */; }; - B5012E6F2C96255A00AC4D68 /* MapEdges.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E672C96255A00AC4D68 /* MapEdges.swift */; }; - B5012E702C96255A00AC4D68 /* MapOpportunities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E692C96255A00AC4D68 /* MapOpportunities.swift */; }; - B5012E712C96255A00AC4D68 /* MapBlockers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E682C96255A00AC4D68 /* MapBlockers.swift */; }; B5012E742C9625E200AC4D68 /* Patterns in Frameworks */ = {isa = PBXBuildFile; productRef = B5012E732C9625E200AC4D68 /* Patterns */; }; - B5012E7A2C96F02F00AC4D68 /* Dimensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E792C96F02E00AC4D68 /* Dimensions.swift */; }; - B5012E7C2C972B6C00AC4D68 /* GroupParserStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E7B2C972B6600AC4D68 /* GroupParserStrategy.swift */; }; B5012E7F2C97315800AC4D68 /* ConcaveHull in Frameworks */ = {isa = PBXBuildFile; productRef = B5012E7E2C97315800AC4D68 /* ConcaveHull */; }; - B5012E812C97318600AC4D68 /* MapGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E802C97318300AC4D68 /* MapGroups.swift */; }; - B5012E872C97874600AC4D68 /* MapGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E862C97874400AC4D68 /* MapGroup.swift */; }; - B5012E8A2C98235500AC4D68 /* MapCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E892C98235300AC4D68 /* MapCommands.swift */; }; - B5012E8C2C98244000AC4D68 /* ViewStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E8B2C98243E00AC4D68 /* ViewStyle.swift */; }; - B5012E8E2C9828D000AC4D68 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5012E8D2C9828CE00AC4D68 /* Constants.swift */; }; B51A45F52C982FAE00870ED5 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = B51A45F42C982FAE00870ED5 /* Sparkle */; }; - B51A45F72C982FEA00870ED5 /* UpdateCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51A45F62C982FE700870ED5 /* UpdateCommands.swift */; }; - B54587102C961E9C0067B788 /* MapApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B545870F2C961E9C0067B788 /* MapApp.swift */; }; - B54587122C961E9C0067B788 /* MapDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54587112C961E9C0067B788 /* MapDocument.swift */; }; - B54587142C961E9C0067B788 /* MapEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54587132C961E9C0067B788 /* MapEditor.swift */; }; - B54587162C961E9E0067B788 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B54587152C961E9E0067B788 /* Assets.xcassets */; }; - B54587192C961E9E0067B788 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B54587182C961E9E0067B788 /* Preview Assets.xcassets */; }; - B54587252C961E9E0067B788 /* MapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54587242C961E9E0067B788 /* MapTests.swift */; }; - B545872F2C961E9E0067B788 /* MapUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B545872E2C961E9E0067B788 /* MapUITests.swift */; }; - B54587312C961E9E0067B788 /* MapUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54587302C961E9E0067B788 /* MapUITestsLaunchTests.swift */; }; - B5D42DC22C984E870075473D /* FocusedValues+document.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D42DC12C984E7F0075473D /* FocusedValues+document.swift */; }; - B5D42DC42C9851ED0075473D /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D42DC32C9851ED0075473D /* SearchBar.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -58,65 +18,39 @@ containerPortal = B54587042C961E9C0067B788 /* Project object */; proxyType = 1; remoteGlobalIDString = B545870B2C961E9C0067B788; - remoteInfo = Map2; + remoteInfo = Map; }; B545872B2C961E9E0067B788 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = B54587042C961E9C0067B788 /* Project object */; proxyType = 1; remoteGlobalIDString = B545870B2C961E9C0067B788; - remoteInfo = Map2; + remoteInfo = Map; }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - B5012E3E2C96232300AC4D68 /* EvolutionPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvolutionPicker.swift; sourceTree = "<group>"; }; - B5012E412C96235B00AC4D68 /* Stage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stage.swift; sourceTree = "<group>"; }; - B5012E442C9623C500AC4D68 /* Font+theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Font+theme.swift"; sourceTree = "<group>"; }; - B5012E462C96243500AC4D68 /* MapTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTextEditor.swift; sourceTree = "<group>"; }; - B5012E482C96245800AC4D68 /* Color+theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+theme.swift"; sourceTree = "<group>"; }; - B5012E4A2C96246D00AC4D68 /* NSColor+theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSColor+theme.swift"; sourceTree = "<group>"; }; - B5012E4C2C96249400AC4D68 /* VertexParserStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VertexParserStrategy.swift; sourceTree = "<group>"; }; - B5012E4D2C96249400AC4D68 /* NoteParserStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteParserStrategy.swift; sourceTree = "<group>"; }; - B5012E4E2C96249400AC4D68 /* EdgeParserStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EdgeParserStrategy.swift; sourceTree = "<group>"; }; - B5012E4F2C96249400AC4D68 /* BlockerParserStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockerParserStrategy.swift; sourceTree = "<group>"; }; - B5012E502C96249400AC4D68 /* OpportunityParserStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpportunityParserStrategy.swift; sourceTree = "<group>"; }; - B5012E512C96249400AC4D68 /* StageParserStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StageParserStrategy.swift; sourceTree = "<group>"; }; - B5012E532C96249400AC4D68 /* MapParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapParser.swift; sourceTree = "<group>"; }; - B5012E552C96249400AC4D68 /* Debouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debouncer.swift; sourceTree = "<group>"; }; - B5012E5F2C96254700AC4D68 /* MapRenderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapRenderView.swift; sourceTree = "<group>"; }; - B5012E632C96255A00AC4D68 /* MapAxes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapAxes.swift; sourceTree = "<group>"; }; - B5012E642C96255A00AC4D68 /* MapStages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapStages.swift; sourceTree = "<group>"; }; - B5012E652C96255A00AC4D68 /* MapVertices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapVertices.swift; sourceTree = "<group>"; }; - B5012E662C96255A00AC4D68 /* MapNotes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapNotes.swift; sourceTree = "<group>"; }; - B5012E672C96255A00AC4D68 /* MapEdges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapEdges.swift; sourceTree = "<group>"; }; - B5012E682C96255A00AC4D68 /* MapBlockers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapBlockers.swift; sourceTree = "<group>"; }; - B5012E692C96255A00AC4D68 /* MapOpportunities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapOpportunities.swift; sourceTree = "<group>"; }; - B5012E792C96F02E00AC4D68 /* Dimensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dimensions.swift; sourceTree = "<group>"; }; - B5012E7B2C972B6600AC4D68 /* GroupParserStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupParserStrategy.swift; sourceTree = "<group>"; }; - B5012E802C97318300AC4D68 /* MapGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapGroups.swift; sourceTree = "<group>"; }; - B5012E862C97874400AC4D68 /* MapGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapGroup.swift; sourceTree = "<group>"; }; - B5012E892C98235300AC4D68 /* MapCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapCommands.swift; sourceTree = "<group>"; }; - B5012E8B2C98243E00AC4D68 /* ViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewStyle.swift; sourceTree = "<group>"; }; - B5012E8D2C9828CE00AC4D68 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = "<group>"; }; - B51A45F62C982FE700870ED5 /* UpdateCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateCommands.swift; sourceTree = "<group>"; }; B545870C2C961E9C0067B788 /* Map.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Map.app; sourceTree = BUILT_PRODUCTS_DIR; }; - B545870F2C961E9C0067B788 /* MapApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapApp.swift; sourceTree = "<group>"; }; - B54587112C961E9C0067B788 /* MapDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDocument.swift; sourceTree = "<group>"; }; - B54587132C961E9C0067B788 /* MapEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapEditor.swift; sourceTree = "<group>"; }; - B54587152C961E9E0067B788 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; - B54587182C961E9E0067B788 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; }; - B545871A2C961E9E0067B788 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; - B545871B2C961E9E0067B788 /* Map.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Map.entitlements; sourceTree = "<group>"; }; B54587202C961E9E0067B788 /* MapTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MapTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - B54587242C961E9E0067B788 /* MapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTests.swift; sourceTree = "<group>"; }; - B545872A2C961E9E0067B788 /* Map2UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Map2UITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - B545872E2C961E9E0067B788 /* MapUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapUITests.swift; sourceTree = "<group>"; }; - B54587302C961E9E0067B788 /* MapUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapUITestsLaunchTests.swift; sourceTree = "<group>"; }; - B5D42DC12C984E7F0075473D /* FocusedValues+document.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FocusedValues+document.swift"; sourceTree = "<group>"; }; - B5D42DC32C9851ED0075473D /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = "<group>"; }; + B545872A2C961E9E0067B788 /* MapUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MapUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + B59830752E17E7C700F5FBF6 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = B545870B2C961E9C0067B788 /* Map */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + B598304E2E17E7C700F5FBF6 /* Map */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (B59830752E17E7C700F5FBF6 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Map; sourceTree = "<group>"; }; + B59830772E17E7CC00F5FBF6 /* MapTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = MapTests; sourceTree = "<group>"; }; + B598307C2E17E7D600F5FBF6 /* MapUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = MapUITests; sourceTree = "<group>"; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ B54587092C961E9C0067B788 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -145,131 +79,12 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - B5012E3C2C96222E00AC4D68 /* Data */ = { - isa = PBXGroup; - children = ( - B5D42DC12C984E7F0075473D /* FocusedValues+document.swift */, - B5012E412C96235B00AC4D68 /* Stage.swift */, - B54587112C961E9C0067B788 /* MapDocument.swift */, - ); - path = Data; - sourceTree = "<group>"; - }; - B5012E3D2C96223800AC4D68 /* Presentation */ = { - isa = PBXGroup; - children = ( - B5012E8B2C98243E00AC4D68 /* ViewStyle.swift */, - B5012E882C98234F00AC4D68 /* Commands */, - B5012E612C96254700AC4D68 /* Complex Components */, - B5012E432C9623BC00AC4D68 /* Theme */, - B5012E402C96232E00AC4D68 /* Base Components */, - B54587132C961E9C0067B788 /* MapEditor.swift */, - ); - path = Presentation; - sourceTree = "<group>"; - }; - B5012E402C96232E00AC4D68 /* Base Components */ = { - isa = PBXGroup; - children = ( - B5012E6A2C96255A00AC4D68 /* MapRender */, - B5012E462C96243500AC4D68 /* MapTextEditor.swift */, - B5012E3E2C96232300AC4D68 /* EvolutionPicker.swift */, - B5D42DC32C9851ED0075473D /* SearchBar.swift */, - ); - path = "Base Components"; - sourceTree = "<group>"; - }; - B5012E432C9623BC00AC4D68 /* Theme */ = { - isa = PBXGroup; - children = ( - B5012E792C96F02E00AC4D68 /* Dimensions.swift */, - B5012E4A2C96246D00AC4D68 /* NSColor+theme.swift */, - B5012E482C96245800AC4D68 /* Color+theme.swift */, - B5012E442C9623C500AC4D68 /* Font+theme.swift */, - ); - path = Theme; - sourceTree = "<group>"; - }; - B5012E522C96249400AC4D68 /* Strategies */ = { - isa = PBXGroup; - children = ( - B5012E7B2C972B6600AC4D68 /* GroupParserStrategy.swift */, - B5012E4C2C96249400AC4D68 /* VertexParserStrategy.swift */, - B5012E4D2C96249400AC4D68 /* NoteParserStrategy.swift */, - B5012E4E2C96249400AC4D68 /* EdgeParserStrategy.swift */, - B5012E4F2C96249400AC4D68 /* BlockerParserStrategy.swift */, - B5012E502C96249400AC4D68 /* OpportunityParserStrategy.swift */, - B5012E512C96249400AC4D68 /* StageParserStrategy.swift */, - ); - path = Strategies; - sourceTree = "<group>"; - }; - B5012E542C96249400AC4D68 /* MapParser */ = { - isa = PBXGroup; - children = ( - B5012E522C96249400AC4D68 /* Strategies */, - B5012E532C96249400AC4D68 /* MapParser.swift */, - ); - path = MapParser; - sourceTree = "<group>"; - }; - B5012E562C96249400AC4D68 /* Logic */ = { - isa = PBXGroup; - children = ( - B5012E8D2C9828CE00AC4D68 /* Constants.swift */, - B5012E542C96249400AC4D68 /* MapParser */, - B5012E552C96249400AC4D68 /* Debouncer.swift */, - ); - path = Logic; - sourceTree = "<group>"; - }; - B5012E602C96254700AC4D68 /* MapRender */ = { - isa = PBXGroup; - children = ( - B5012E5F2C96254700AC4D68 /* MapRenderView.swift */, - ); - path = MapRender; - sourceTree = "<group>"; - }; - B5012E612C96254700AC4D68 /* Complex Components */ = { - isa = PBXGroup; - children = ( - B5012E602C96254700AC4D68 /* MapRender */, - ); - path = "Complex Components"; - sourceTree = "<group>"; - }; - B5012E6A2C96255A00AC4D68 /* MapRender */ = { - isa = PBXGroup; - children = ( - B5012E802C97318300AC4D68 /* MapGroups.swift */, - B5012E862C97874400AC4D68 /* MapGroup.swift */, - B5012E632C96255A00AC4D68 /* MapAxes.swift */, - B5012E642C96255A00AC4D68 /* MapStages.swift */, - B5012E652C96255A00AC4D68 /* MapVertices.swift */, - B5012E662C96255A00AC4D68 /* MapNotes.swift */, - B5012E672C96255A00AC4D68 /* MapEdges.swift */, - B5012E682C96255A00AC4D68 /* MapBlockers.swift */, - B5012E692C96255A00AC4D68 /* MapOpportunities.swift */, - ); - path = MapRender; - sourceTree = "<group>"; - }; - B5012E882C98234F00AC4D68 /* Commands */ = { - isa = PBXGroup; - children = ( - B51A45F62C982FE700870ED5 /* UpdateCommands.swift */, - B5012E892C98235300AC4D68 /* MapCommands.swift */, - ); - path = Commands; - sourceTree = "<group>"; - }; B54587032C961E9C0067B788 = { isa = PBXGroup; children = ( - B545870E2C961E9C0067B788 /* Map */, - B54587232C961E9E0067B788 /* MapTests */, - B545872D2C961E9E0067B788 /* MapUITests */, + B598304E2E17E7C700F5FBF6 /* Map */, + B59830772E17E7CC00F5FBF6 /* MapTests */, + B598307C2E17E7D600F5FBF6 /* MapUITests */, B545870D2C961E9C0067B788 /* Products */, ); sourceTree = "<group>"; @@ -279,51 +94,11 @@ children = ( B545870C2C961E9C0067B788 /* Map.app */, B54587202C961E9E0067B788 /* MapTests.xctest */, - B545872A2C961E9E0067B788 /* Map2UITests.xctest */, + B545872A2C961E9E0067B788 /* MapUITests.xctest */, ); name = Products; sourceTree = "<group>"; }; - B545870E2C961E9C0067B788 /* Map */ = { - isa = PBXGroup; - children = ( - B5012E562C96249400AC4D68 /* Logic */, - B5012E3C2C96222E00AC4D68 /* Data */, - B5012E3D2C96223800AC4D68 /* Presentation */, - B545870F2C961E9C0067B788 /* MapApp.swift */, - B54587152C961E9E0067B788 /* Assets.xcassets */, - B545871A2C961E9E0067B788 /* Info.plist */, - B545871B2C961E9E0067B788 /* Map.entitlements */, - B54587172C961E9E0067B788 /* Preview Content */, - ); - path = Map; - sourceTree = "<group>"; - }; - B54587172C961E9E0067B788 /* Preview Content */ = { - isa = PBXGroup; - children = ( - B54587182C961E9E0067B788 /* Preview Assets.xcassets */, - ); - path = "Preview Content"; - sourceTree = "<group>"; - }; - B54587232C961E9E0067B788 /* MapTests */ = { - isa = PBXGroup; - children = ( - B54587242C961E9E0067B788 /* MapTests.swift */, - ); - path = MapTests; - sourceTree = "<group>"; - }; - B545872D2C961E9E0067B788 /* MapUITests */ = { - isa = PBXGroup; - children = ( - B545872E2C961E9E0067B788 /* MapUITests.swift */, - B54587302C961E9E0067B788 /* MapUITestsLaunchTests.swift */, - ); - path = MapUITests; - sourceTree = "<group>"; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -339,13 +114,16 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + B598304E2E17E7C700F5FBF6 /* Map */, + ); name = Map; packageProductDependencies = ( B5012E732C9625E200AC4D68 /* Patterns */, B5012E7E2C97315800AC4D68 /* ConcaveHull */, B51A45F42C982FAE00870ED5 /* Sparkle */, ); - productName = Map2; + productName = Map; productReference = B545870C2C961E9C0067B788 /* Map.app */; productType = "com.apple.product-type.application"; }; @@ -362,14 +140,17 @@ dependencies = ( B54587222C961E9E0067B788 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + B59830772E17E7CC00F5FBF6 /* MapTests */, + ); name = MapTests; - productName = Map2Tests; + productName = MapTests; productReference = B54587202C961E9E0067B788 /* MapTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; - B54587292C961E9E0067B788 /* Map2UITests */ = { + B54587292C961E9E0067B788 /* MapUITests */ = { isa = PBXNativeTarget; - buildConfigurationList = B545873A2C961E9E0067B788 /* Build configuration list for PBXNativeTarget "Map2UITests" */; + buildConfigurationList = B545873A2C961E9E0067B788 /* Build configuration list for PBXNativeTarget "MapUITests" */; buildPhases = ( B54587262C961E9E0067B788 /* Sources */, B54587272C961E9E0067B788 /* Frameworks */, @@ -380,9 +161,12 @@ dependencies = ( B545872C2C961E9E0067B788 /* PBXTargetDependency */, ); - name = Map2UITests; - productName = Map2UITests; - productReference = B545872A2C961E9E0067B788 /* Map2UITests.xctest */; + fileSystemSynchronizedGroups = ( + B598307C2E17E7D600F5FBF6 /* MapUITests */, + ); + name = MapUITests; + productName = MapUITests; + productReference = B545872A2C961E9E0067B788 /* MapUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; /* End PBXNativeTarget section */ @@ -393,7 +177,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1600; - LastUpgradeCheck = 1600; + LastUpgradeCheck = 1640; TargetAttributes = { B545870B2C961E9C0067B788 = { CreatedOnToolsVersion = 16.0; @@ -428,7 +212,7 @@ targets = ( B545870B2C961E9C0067B788 /* Map */, B545871F2C961E9E0067B788 /* MapTests */, - B54587292C961E9E0067B788 /* Map2UITests */, + B54587292C961E9E0067B788 /* MapUITests */, ); }; /* End PBXProject section */ @@ -438,8 +222,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - B54587192C961E9E0067B788 /* Preview Assets.xcassets in Resources */, - B54587162C961E9E0067B788 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -464,41 +246,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - B5012E872C97874600AC4D68 /* MapGroup.swift in Sources */, - B5012E472C96243C00AC4D68 /* MapTextEditor.swift in Sources */, - B5012E622C96254700AC4D68 /* MapRenderView.swift in Sources */, - B5012E492C96245B00AC4D68 /* Color+theme.swift in Sources */, - B54587122C961E9C0067B788 /* MapDocument.swift in Sources */, - B54587102C961E9C0067B788 /* MapApp.swift in Sources */, - B5012E8C2C98244000AC4D68 /* ViewStyle.swift in Sources */, - B5D42DC42C9851ED0075473D /* SearchBar.swift in Sources */, - B5012E8E2C9828D000AC4D68 /* Constants.swift in Sources */, - B5012E7C2C972B6C00AC4D68 /* GroupParserStrategy.swift in Sources */, - B5012E6B2C96255A00AC4D68 /* MapAxes.swift in Sources */, - B5012E6C2C96255A00AC4D68 /* MapVertices.swift in Sources */, - B5012E6D2C96255A00AC4D68 /* MapStages.swift in Sources */, - B5012E7A2C96F02F00AC4D68 /* Dimensions.swift in Sources */, - B5012E6E2C96255A00AC4D68 /* MapNotes.swift in Sources */, - B5012E6F2C96255A00AC4D68 /* MapEdges.swift in Sources */, - B5012E702C96255A00AC4D68 /* MapOpportunities.swift in Sources */, - B5012E712C96255A00AC4D68 /* MapBlockers.swift in Sources */, - B5012E4B2C96246F00AC4D68 /* NSColor+theme.swift in Sources */, - B54587142C961E9C0067B788 /* MapEditor.swift in Sources */, - B5012E3F2C96232A00AC4D68 /* EvolutionPicker.swift in Sources */, - B5012E572C96249400AC4D68 /* NoteParserStrategy.swift in Sources */, - B51A45F72C982FEA00870ED5 /* UpdateCommands.swift in Sources */, - B5012E582C96249400AC4D68 /* BlockerParserStrategy.swift in Sources */, - B5012E592C96249400AC4D68 /* VertexParserStrategy.swift in Sources */, - B5012E5A2C96249400AC4D68 /* MapParser.swift in Sources */, - B5012E5B2C96249400AC4D68 /* OpportunityParserStrategy.swift in Sources */, - B5012E5C2C96249400AC4D68 /* EdgeParserStrategy.swift in Sources */, - B5012E5D2C96249400AC4D68 /* StageParserStrategy.swift in Sources */, - B5D42DC22C984E870075473D /* FocusedValues+document.swift in Sources */, - B5012E812C97318600AC4D68 /* MapGroups.swift in Sources */, - B5012E8A2C98235500AC4D68 /* MapCommands.swift in Sources */, - B5012E5E2C96249400AC4D68 /* Debouncer.swift in Sources */, - B5012E452C9623C700AC4D68 /* Font+theme.swift in Sources */, - B5012E422C96235E00AC4D68 /* Stage.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -506,7 +253,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - B54587252C961E9E0067B788 /* MapTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -514,8 +260,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - B54587312C961E9E0067B788 /* MapUITestsLaunchTests.swift in Sources */, - B545872F2C961E9E0067B788 /* MapUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -569,7 +313,9 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = S68NHQVJXW; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -632,7 +378,9 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = S68NHQVJXW; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -662,8 +410,8 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 5; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Map/Preview Content\""; - DEVELOPMENT_TEAM = S68NHQVJXW; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -679,7 +427,8 @@ PRODUCT_BUNDLE_IDENTIFIER = systems.tranquil.Map; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -692,8 +441,8 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 5; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Map/Preview Content\""; - DEVELOPMENT_TEAM = S68NHQVJXW; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -709,7 +458,8 @@ PRODUCT_BUNDLE_IDENTIFIER = systems.tranquil.Map; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; }; name = Release; }; @@ -719,15 +469,15 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = S68NHQVJXW; + DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = systems.tranquil.Map2Tests; + PRODUCT_BUNDLE_IDENTIFIER = systems.tranquil.MapTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Map2.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Map2"; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Map.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Map"; }; name = Debug; }; @@ -737,15 +487,15 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = S68NHQVJXW; + DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = systems.tranquil.Map2Tests; + PRODUCT_BUNDLE_IDENTIFIER = systems.tranquil.MapTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Map2.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Map2"; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Map.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Map"; }; name = Release; }; @@ -754,14 +504,14 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = S68NHQVJXW; + DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = systems.tranquil.Map2UITests; + PRODUCT_BUNDLE_IDENTIFIER = systems.tranquil.MapUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TEST_TARGET_NAME = Map2; + SWIFT_VERSION = 6.0; + TEST_TARGET_NAME = Map; }; name = Debug; }; @@ -770,14 +520,14 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = S68NHQVJXW; + DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = systems.tranquil.Map2UITests; + PRODUCT_BUNDLE_IDENTIFIER = systems.tranquil.MapUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TEST_TARGET_NAME = Map2; + SWIFT_VERSION = 6.0; + TEST_TARGET_NAME = Map; }; name = Release; }; @@ -811,7 +561,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - B545873A2C961E9E0067B788 /* Build configuration list for PBXNativeTarget "Map2UITests" */ = { + B545873A2C961E9E0067B788 /* Build configuration list for PBXNativeTarget "MapUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( B545873B2C961E9E0067B788 /* Debug */, diff --git a/Map/Assets.xcassets/Colors/Theme/JustWhite.colorset/Contents.json b/Map/Assets.xcassets/Colors/Theme/JustWhite.colorset/Contents.json new file mode 100644 index 0000000..97650a1 --- /dev/null +++ b/Map/Assets.xcassets/Colors/Theme/JustWhite.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Map/Logic/Constants.swift b/Map/Business/Constants.swift index 1c6f54d..1c6f54d 100644 --- a/Map/Logic/Constants.swift +++ b/Map/Business/Constants.swift diff --git a/Map/Logic/MapParser/MapParser.swift b/Map/Business/MapParser/MapParser.swift index e94de77..e94de77 100644 --- a/Map/Logic/MapParser/MapParser.swift +++ b/Map/Business/MapParser/MapParser.swift diff --git a/Map/Logic/MapParser/Strategies/BlockerParserStrategy.swift b/Map/Business/MapParser/Strategies/BlockerParserStrategy.swift index 4bd2ce4..4bd2ce4 100644 --- a/Map/Logic/MapParser/Strategies/BlockerParserStrategy.swift +++ b/Map/Business/MapParser/Strategies/BlockerParserStrategy.swift diff --git a/Map/Logic/MapParser/Strategies/EdgeParserStrategy.swift b/Map/Business/MapParser/Strategies/EdgeParserStrategy.swift index cc29b49..cc29b49 100644 --- a/Map/Logic/MapParser/Strategies/EdgeParserStrategy.swift +++ b/Map/Business/MapParser/Strategies/EdgeParserStrategy.swift diff --git a/Map/Logic/MapParser/Strategies/GroupParserStrategy.swift b/Map/Business/MapParser/Strategies/GroupParserStrategy.swift index 96de365..96de365 100644 --- a/Map/Logic/MapParser/Strategies/GroupParserStrategy.swift +++ b/Map/Business/MapParser/Strategies/GroupParserStrategy.swift diff --git a/Map/Logic/MapParser/Strategies/NoteParserStrategy.swift b/Map/Business/MapParser/Strategies/NoteParserStrategy.swift index d06c768..d06c768 100644 --- a/Map/Logic/MapParser/Strategies/NoteParserStrategy.swift +++ b/Map/Business/MapParser/Strategies/NoteParserStrategy.swift diff --git a/Map/Logic/MapParser/Strategies/OpportunityParserStrategy.swift b/Map/Business/MapParser/Strategies/OpportunityParserStrategy.swift index 25e6cc4..25e6cc4 100644 --- a/Map/Logic/MapParser/Strategies/OpportunityParserStrategy.swift +++ b/Map/Business/MapParser/Strategies/OpportunityParserStrategy.swift diff --git a/Map/Logic/MapParser/Strategies/StageParserStrategy.swift b/Map/Business/MapParser/Strategies/StageParserStrategy.swift index 6e4a341..6e4a341 100644 --- a/Map/Logic/MapParser/Strategies/StageParserStrategy.swift +++ b/Map/Business/MapParser/Strategies/StageParserStrategy.swift diff --git a/Map/Logic/MapParser/Strategies/VertexParserStrategy.swift b/Map/Business/MapParser/Strategies/VertexParserStrategy.swift index 0ca6fc1..0ca6fc1 100644 --- a/Map/Logic/MapParser/Strategies/VertexParserStrategy.swift +++ b/Map/Business/MapParser/Strategies/VertexParserStrategy.swift diff --git a/Map/Data/Stage.swift b/Map/Business/Stage.swift index 4f737a1..4f737a1 100644 --- a/Map/Data/Stage.swift +++ b/Map/Business/Stage.swift diff --git a/Map/Logic/Debouncer.swift b/Map/Data/Debouncer.swift index 8d40e99..8d40e99 100644 --- a/Map/Logic/Debouncer.swift +++ b/Map/Data/Debouncer.swift diff --git a/Map/Data/MapDocument.swift b/Map/Data/MapDocument.swift index 1b43efb..3c6d352 100644 --- a/Map/Data/MapDocument.swift +++ b/Map/Data/MapDocument.swift @@ -16,7 +16,7 @@ import SwiftUI import UniformTypeIdentifiers extension UTType { - static var exampleText: UTType { + static var wmap: UTType { UTType(importedAs: "systems.tranquil.map.wmap") } } @@ -24,11 +24,23 @@ extension UTType { struct MapDocument: FileDocument { var text: String - init(text: String = "Hello, world!") { + init( + text: String = """ + Anchor (60, 5) [x] + Node A (78, 23) + Node B (40, 30) [square] + Node C (65, 70) + + Anchor -> Node A + Anchor -> Node B + Node A -> Node C + Node B -> Node C + """ + ) { self.text = text } - static var readableContentTypes: [UTType] { [.exampleText] } + static var readableContentTypes: [UTType] { [.wmap] } init(configuration: ReadConfiguration) throws { guard let data = configuration.file.regularFileContents, diff --git a/Map/Info.plist b/Map/Info.plist index e862423..07f3108 100644 --- a/Map/Info.plist +++ b/Map/Info.plist @@ -2,6 +2,12 @@ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> + <key>UIAppFonts</key> + <array> + <string>Ronzino-Regular.otf</string> + <string>Ronzino-Medium.otf</string> + <string>LibertinusSerif-Regular.otf</string> + </array> <key>SUEnableInstallerLauncherService</key> <true/> <key>SUPublicEDKey</key> diff --git a/Map/Presentation/Base Components/EvolutionPicker.swift b/Map/Presentation/Base Components/EvolutionPicker.swift index 2768540..2df4a7f 100644 --- a/Map/Presentation/Base Components/EvolutionPicker.swift +++ b/Map/Presentation/Base Components/EvolutionPicker.swift @@ -21,21 +21,22 @@ struct EvolutionPicker: View { var body: some View { Picker("Evolution", selection: $selectedEvolution) { ForEach(StageType.types) { stage in - Text(Stage.title(stage)).font(.Theme.body).tag(stage).padding(4.0) + Text(Stage.title(stage)).font(.Theme.Body.regular).tag(stage).padding(4.0) } Divider() ForEach(StageType.characteristics) { stage in - Text(Stage.title(stage)).font(.Theme.body).tag(stage).padding(4.0).font(.Theme.body) + Text(Stage.title(stage)).font(.Theme.Body.regular).tag(stage).padding(4.0).font( + .Theme.Body.regular) } Divider() ForEach(StageType.properties) { stage in - Text(Stage.title(stage)).font(.Theme.body).tag(stage).padding(4.0) + Text(Stage.title(stage)).font(.Theme.Body.regular).tag(stage).padding(4.0) } Divider() ForEach(StageType.custom) { stage in - Text(Stage.title(stage)).font(.Theme.body).tag(stage).padding(4.0) + Text(Stage.title(stage)).font(.Theme.Body.regular).tag(stage).padding(4.0) } - }.font(.Theme.body).padding(.horizontal, 8.0).padding(.vertical, 4.0) + }.font(.Theme.Body.regular).padding(.horizontal, 8.0).padding(.vertical, 4.0) } } diff --git a/Map/Presentation/Base Components/MapRender/MapAxes.swift b/Map/Presentation/Base Components/MapRender/MapAxes.swift index 4f64135..1c0a147 100644 --- a/Map/Presentation/Base Components/MapRender/MapAxes.swift +++ b/Map/Presentation/Base Components/MapRender/MapAxes.swift @@ -36,11 +36,11 @@ struct MapAxes: View { }.stroke(Color.Map.axisColor, lineWidth: lineWidth * 2) // Y Labels - Text("Visible").font(.Theme.axisLabel).foregroundColor(.Map.labelColor).rotationEffect( + Text("Visible").font(.Theme.Map.axisLabel).foregroundColor(.Map.labelColor).rotationEffect( Angle(degrees: -90.0) ) .offset(CGSize(width: -35.0, height: 0.0)) - Text("Invisible").font(.Theme.axisLabel).foregroundColor(.Map.labelColor).rotationEffect( + Text("Invisible").font(.Theme.Map.axisLabel).foregroundColor(.Map.labelColor).rotationEffect( Angle(degrees: -90.0) ) .offset(CGSize(width: -40.0, height: mapSize.height - 20)) @@ -48,36 +48,36 @@ struct MapAxes: View { // X Labels Text("Uncharted") - .font(.Theme.axisLabel) + .font(.Theme.Map.axisLabel) .foregroundColor(.Map.labelColor) .frame(width: mapSize.width / 4, height: stageHeight / 2.0, alignment: .topLeading) .offset(CGSize(width: 0.0, height: -stageHeight / 4.0)) Text("Industrialised") - .font(.Theme.axisLabel) + .font(.Theme.Map.axisLabel) .foregroundColor(.Map.labelColor) .frame(width: mapSize.width / 4, height: stageHeight / 2.0, alignment: .topLeading) .offset(CGSize(width: mapSize.width - 100.0, height: -stageHeight / 4.0)) Text(evolution.i) - .font(.Theme.axisLabel) + .font(.Theme.Map.axisLabel) .foregroundColor(.Map.labelColor) .frame(width: w(stages[0]), height: stageHeight, alignment: .topLeading) .offset(CGSize(width: 0.0, height: mapSize.height + padding)) Text(evolution.ii) - .font(.Theme.axisLabel) + .font(.Theme.Map.axisLabel) .foregroundColor(.Map.labelColor) .frame(width: w(stages[1]) - w(stages[0]), height: stageHeight, alignment: .topLeading) .offset(CGSize(width: w(stages[0]), height: mapSize.height + padding)) Text(evolution.iii) - .font(.Theme.axisLabel) + .font(.Theme.Map.axisLabel) .foregroundColor(.Map.labelColor) .frame(width: w(stages[2]) - w(stages[1]), height: stageHeight, alignment: .topLeading) .offset(CGSize(width: w(stages[1]), height: mapSize.height + padding)) Text(evolution.iv) - .font(.Theme.axisLabel) + .font(.Theme.Map.axisLabel) .foregroundColor(.Map.labelColor) .frame(width: mapSize.width - w(stages[2]), height: stageHeight, alignment: .topLeading) .offset(CGSize(width: w(stages[2]), height: mapSize.height + padding)) diff --git a/Map/Presentation/Base Components/MapRender/MapNotes.swift b/Map/Presentation/Base Components/MapRender/MapNotes.swift index 0119154..16c61ba 100644 --- a/Map/Presentation/Base Components/MapRender/MapNotes.swift +++ b/Map/Presentation/Base Components/MapRender/MapNotes.swift @@ -24,8 +24,10 @@ struct MapNotes: View { var body: some View { ForEach(notes, id: \.id) { note in - Text(note.text.replacingOccurrences(of: "\\n", with: "\n")).font(.Theme.note) - .padding(2.0) + Text(note.text.replacingOccurrences(of: "\\n", with: "\n")) + .font(.Theme.Map.note) + .lineSpacing(Dimensions.LineHeight.Map.note - Dimensions.FontSize.Map.note) + .padding(Dimensions.Spacing.cozy) .background(.white) .foregroundColor(.Map.labelColor) .border(Color.Map.vertexColor, width: lineWidth) diff --git a/Map/Presentation/Base Components/MapRender/MapVertices.swift b/Map/Presentation/Base Components/MapRender/MapVertices.swift index d8e3b88..1f6cf1a 100644 --- a/Map/Presentation/Base Components/MapRender/MapVertices.swift +++ b/Map/Presentation/Base Components/MapRender/MapVertices.swift @@ -28,7 +28,11 @@ struct MapVertices: View { ForEach(vertices, id: \.id) { vertex in ZStack(alignment: .topLeading) { getVertexShape(vertex).fill(Color.Map.vertexColor) - Text(vertex.label.replacingOccurrences(of: "\\n", with: "\n")).font(.Theme.vertexLabel) + Text(vertex.label.replacingOccurrences(of: "\\n", with: "\n")) + .font(.Theme.Map.vertexLabel) + .lineSpacing( + Dimensions.LineHeight.Map.vertexLabel - Dimensions.FontSize.Map.vertexLabel + ) .foregroundColor(.Map.labelColor) .shadow(color: .white, radius: 0, x: -0.5, y: -0.5) .shadow(color: .white, radius: 0, x: 0.5, y: 0.5) diff --git a/Map/Presentation/Base Components/MapTextEditor.swift b/Map/Presentation/Base Components/MapTextEditor.swift index ff98203..3c5c824 100644 --- a/Map/Presentation/Base Components/MapTextEditor.swift +++ b/Map/Presentation/Base Components/MapTextEditor.swift @@ -150,7 +150,7 @@ extension MapTextEditorController: NSTextStorageDelegate { } } - private func colorizeText(textStorage: NSTextStorage) { + nonisolated private func colorizeText(textStorage: NSTextStorage) { let range = NSMakeRange(0, textStorage.length) var matches = vertexRegex.matches(in: textStorage.string, options: [], range: range) diff --git a/Map/Presentation/Base Components/SearchBar.swift b/Map/Presentation/Base Components/SearchBar.swift index e5182c5..cd2c378 100644 --- a/Map/Presentation/Base Components/SearchBar.swift +++ b/Map/Presentation/Base Components/SearchBar.swift @@ -48,19 +48,19 @@ struct SearchBar: View { Spacer() Button(action: onPrevious) { Image(systemName: "chevron.left") - .font(.Theme.smallControl) + .font(.Theme.SmallControl.regular) }.keyboardShortcut( "g", modifiers: EventModifiers([.command, .shift]) ).help("Find Previous (⇧⌘G)") Button(action: onNext) { Image(systemName: "chevron.right") - .font(.Theme.smallControl) + .font(.Theme.SmallControl.regular) }.keyboardShortcut( "g", modifiers: EventModifiers([.command]) ).help("Find Next (⌘G)") Button(action: onDismiss) { Text("Done") - .font(.Theme.smallControl) + .font(.Theme.SmallControl.regular) }.keyboardShortcut(.escape, modifiers: EventModifiers()) .help("Done (⎋)") }.padding(4.0) diff --git a/Map/Presentation/Complex Components/MapRender/MapRenderView.swift b/Map/Presentation/Complex Components/MapRender/MapRenderView.swift index 1ca1ffe..780290b 100644 --- a/Map/Presentation/Complex Components/MapRender/MapRenderView.swift +++ b/Map/Presentation/Complex Components/MapRender/MapRenderView.swift @@ -30,8 +30,8 @@ struct MapRenderView: View { MapParser.parse(content: document.text) } - let mapSize = Dimensions.mapSize - let padding = Dimensions.mapPadding + let mapSize = Dimensions.Map.size + let padding = Dimensions.Map.padding let lineWidth = CGFloat(0.5) let vertexSize = CGSize(width: 25.0, height: 25.0) diff --git a/Map/Presentation/EvolutionPicker.swift b/Map/Presentation/EvolutionPicker.swift deleted file mode 100644 index 0f4c954..0000000 --- a/Map/Presentation/EvolutionPicker.swift +++ /dev/null @@ -1,41 +0,0 @@ -import SwiftUI - -struct EvolutionPicker: View { - - @EnvironmentObject private var store: AppStore - - private var selectedEvolution: Binding<StageType> { - Binding( - get: { store.state.selectedEvolution }, - set: { evolution in - store.send(.selectEvolution(evolution: evolution)) - } - ) - } - - var body: some View { - Picker("Evolution", selection: selectedEvolution) { - ForEach(StageType.types) { stage in - Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0) - } - Divider() - ForEach(StageType.characteristics) { stage in - Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0).font(.theme.body) - } - Divider() - ForEach(StageType.properties) { stage in - Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0) - } - Divider() - ForEach(StageType.custom) { stage in - Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0) - } - }.font(.theme.body).padding(.horizontal, 8.0).padding(.vertical, 4.0) - } -} - -struct EvolutionPicker_Previews: PreviewProvider { - static var previews: some View { - EvolutionPicker() - } -} diff --git a/Map/Presentation/MapEditor.swift b/Map/Presentation/MapEditor.swift index 5cda8f9..1e25ce8 100644 --- a/Map/Presentation/MapEditor.swift +++ b/Map/Presentation/MapEditor.swift @@ -102,8 +102,8 @@ struct MapEditor: View { MapRenderView( document: $document, evolution: $selectedEvolution, onDragVertex: onDragVertex ).scaleEffect(zoom, anchor: .center).frame( - width: (Dimensions.mapSize.width + 2 * Dimensions.mapPadding) * zoom, - height: (Dimensions.mapSize.height + 2 * Dimensions.mapPadding) * zoom) + width: (Dimensions.Map.size.width + 2 * Dimensions.Map.padding) * zoom, + height: (Dimensions.Map.size.height + 2 * Dimensions.Map.padding) * zoom) }.background(Color.UI.background) .gesture( MagnificationGesture() @@ -125,16 +125,16 @@ struct MapEditor: View { value: $zoom, in: zoomRange, step: 0.1, label: { Text(formatZoom(zoom)) - .font(.Theme.smallControl) + .font(.Theme.SmallControl.regular) }, minimumValueLabel: { Image(systemName: "minus.magnifyingglass") - .font(.Theme.smallControl) + .font(.Theme.SmallControl.regular) .help("Zoom Out (⌘-)") }, maximumValueLabel: { Image(systemName: "plus.magnifyingglass") - .font(.Theme.smallControl) + .font(.Theme.SmallControl.regular) .help("Zoom In (⌘+)") } ).frame(width: 200).padding(.trailing, 10.0) diff --git a/Map/Presentation/Theme/Color+theme.swift b/Map/Presentation/Theme/Color+theme.swift index feac76a..ee80e82 100644 --- a/Map/Presentation/Theme/Color+theme.swift +++ b/Map/Presentation/Theme/Color+theme.swift @@ -16,6 +16,9 @@ import SwiftUI extension Color { struct Theme { + + // MARK: - Base Palette + static let darkSlate = Color("Dark Slate") static let jasperRed = Color("Jasper Red") static let olympicBlue = Color("Olympic Blue") @@ -28,6 +31,15 @@ extension Color { static let darkerNeutralGray = Color("Darker Neutral Gray") } + // MARK: - General + + struct UI { + static let foreground = Color("Foreground") + static let background = Color("Background") + } + + // MARK: - The Map + struct Map { static let labelColor = Color.Theme.darkSlate static let axisColor = Color.Theme.darkSlate @@ -44,9 +56,4 @@ extension Color { Color.Theme.hermosaPink, ] } - - struct UI { - static let foreground = Color("Foreground") - static let background = Color("Background") - } } diff --git a/Map/Presentation/Theme/Dimensions.swift b/Map/Presentation/Theme/Dimensions.swift index 99a6207..2e1fd69 100644 --- a/Map/Presentation/Theme/Dimensions.swift +++ b/Map/Presentation/Theme/Dimensions.swift @@ -15,6 +15,48 @@ import Foundation struct Dimensions { - static let mapSize = CGSize(width: 1300.0, height: 1000.0) - static let mapPadding: CGFloat = 42.0 + + // MARK: - Fonts + + struct FontSize { + static let body: CGFloat = 16 + static let title: CGFloat = 32 + static let caption: CGFloat = 14 + static let smallControl: CGFloat = 9 + + struct Map { + static let note: CGFloat = 12 + static let axisLabel: CGFloat = 14 + static let vertexLabel: CGFloat = 12 + } + } + + struct LineHeight { + static let body: CGFloat = 18 + static let title: CGFloat = 38 + static let caption: CGFloat = 18 + static let smallControl: CGFloat = 18 + + struct Map { + static let note: CGFloat = 18 + static let axisLabel: CGFloat = 18 + static let vertexLabel: CGFloat = 18 + } + } + + // MARK: - Spacing + + struct Spacing { + static let coziest: CGFloat = 3.0 + static let cozy: CGFloat = 5.0 + static let regular: CGFloat = 8.0 + static let loose: CGFloat = 12.0 + static let indulgent: CGFloat = 16.0 + } + + // MARK: - The Map + struct Map { + static let size = CGSize(width: 1300.0, height: 1000.0) + static let padding: CGFloat = 42.0 + } } diff --git a/Map/Presentation/Theme/Font+theme.swift b/Map/Presentation/Theme/Font+theme.swift index 1a9abb8..2fb56f7 100644 --- a/Map/Presentation/Theme/Font+theme.swift +++ b/Map/Presentation/Theme/Font+theme.swift @@ -15,6 +15,19 @@ import SwiftUI extension Font { + + static func libertinus(size: CGFloat) -> Font { + return .custom("Libertinus Serif", size: size) + } + + static func ronzino(size: CGFloat) -> Font { + return .custom("Ronzino", size: size) + } + + static func ronzinoMedium(size: CGFloat) -> Font { + return .custom("RonzinoMedium", size: size) + } + public struct Theme { // Map @@ -22,8 +35,30 @@ extension Font { static let axisLabel = Font.system(size: 14, design: .serif) static let vertexLabel = Font.system(size: 12, design: .serif) - // UI - static let smallControl = Font.system(size: 9) - static let body = Font.system(size: 13) + struct Body { + static let regular = Font.ronzino(size: Dimensions.FontSize.body).weight(.regular) + static let emphasized = Font.ronzino(size: Dimensions.FontSize.body).weight(.medium) + } + + struct Title { + static let emphasized = Font.libertinus(size: Dimensions.FontSize.title).weight(.medium) + } + + struct Caption { + static let regular = Font.ronzino(size: Dimensions.FontSize.caption).weight(.regular) + static let emphasized = Font.ronzino(size: Dimensions.FontSize.caption).weight(.medium) + } + + struct SmallControl { + static let regular = Font.ronzino(size: Dimensions.FontSize.smallControl).weight(.regular) + } + + struct Map { + static let note = Font.libertinus(size: Dimensions.FontSize.Map.note).weight(.regular) + static let axisLabel = Font.libertinus(size: Dimensions.FontSize.Map.axisLabel).weight( + .regular) + static let vertexLabel = Font.libertinus(size: Dimensions.FontSize.Map.vertexLabel).weight( + .regular) + } } } diff --git a/Map/Presentation/Theme/LibertinusSerif-Regular.otf b/Map/Presentation/Theme/LibertinusSerif-Regular.otf Binary files differnew file mode 100644 index 0000000..508f3c0 --- /dev/null +++ b/Map/Presentation/Theme/LibertinusSerif-Regular.otf diff --git a/Map/Presentation/Theme/Ronzino-Medium.otf b/Map/Presentation/Theme/Ronzino-Medium.otf Binary files differnew file mode 100644 index 0000000..0be8dd5 --- /dev/null +++ b/Map/Presentation/Theme/Ronzino-Medium.otf diff --git a/Map/Presentation/Theme/Ronzino-Regular.otf b/Map/Presentation/Theme/Ronzino-Regular.otf Binary files differnew file mode 100644 index 0000000..8b70471 --- /dev/null +++ b/Map/Presentation/Theme/Ronzino-Regular.otf diff --git a/MapTests/DebouncerTests.swift b/MapTests/DebouncerTests.swift new file mode 100644 index 0000000..9ac36cd --- /dev/null +++ b/MapTests/DebouncerTests.swift @@ -0,0 +1,71 @@ +// Copyright (C) 2024 Rubén Beltrán del Río + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Foundation +// You should have received a copy of the GNU General Public License +// along with this program. If not, see https://map.tranquil.systems. +import Testing + +@testable import Map + +struct DebouncerTests { + + @Test func testDebouncerExecutesAfterDelay() async throws { + let debouncer = Debouncer(seconds: 0.1) + var executed = false + + debouncer.debounce { + executed = true + } + + #expect(executed == false) + + try await Task.sleep(nanoseconds: 150_000_000) // 0.15 seconds + + #expect(executed == true) + } + + @Test func testDebouncerCancelsReplacement() async throws { + let debouncer = Debouncer(seconds: 0.1) + var firstExecuted = false + var secondExecuted = false + + debouncer.debounce { + firstExecuted = true + } + + debouncer.debounce { + secondExecuted = true + } + + try await Task.sleep(nanoseconds: 150_000_000) // 0.15 seconds + + #expect(firstExecuted == false) + #expect(secondExecuted == true) + } + + @Test func testDebouncerMultipleReplacements() async throws { + let debouncer = Debouncer(seconds: 0.1) + var counter = 0 + + for i in 1...5 { + debouncer.debounce { + counter += i + } + } + + try await Task.sleep(nanoseconds: 150_000_000) // 0.15 seconds + + #expect(counter == 5) + } + +} diff --git a/MapTests/MapDocumentTests.swift b/MapTests/MapDocumentTests.swift new file mode 100644 index 0000000..7ae93fc --- /dev/null +++ b/MapTests/MapDocumentTests.swift @@ -0,0 +1,81 @@ +// Copyright (C) 2024 Rubén Beltrán del Río + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import SwiftUI +// You should have received a copy of the GNU General Public License +// along with this program. If not, see https://map.tranquil.systems. +import Testing +import UniformTypeIdentifiers + +@testable import Map + +struct MapDocumentTests { + + @Test func testMapDocumentInitialization() async throws { + let document = MapDocument() + #expect( + document.text == """ + Anchor (60, 5) [x] + Node A (78, 23) + Node B (40, 30) [square] + Node C (65, 70) + + Anchor -> Node A + Anchor -> Node B + Node A -> Node C + Node B -> Node C + """) + } + + @Test func testMapDocumentInitializationWithText() async throws { + let testText = "Test map content" + let document = MapDocument(text: testText) + #expect(document.text == testText) + } + + @Test func testMapDocumentReadableContentTypes() async throws { + let contentTypes = MapDocument.readableContentTypes + #expect(contentTypes.count == 1) + #expect(contentTypes.first == UTType.wmap) + } + + @Test func testMapDocumentTextProperty() async throws { + let document = MapDocument(text: "Test content") + #expect(document.text == "Test content") + + // Test that the document can handle different text content + let mapContent = "Node A (10, 20)\nNode B (30, 40)\nNode A -> Node B" + let mapDocument = MapDocument(text: mapContent) + #expect(mapDocument.text == mapContent) + } + + @Test func testMapDocumentWithEmptyContent() async throws { + let document = MapDocument(text: "") + #expect(document.text == "") + } + + @Test func testMapDocumentExportAsImage() async throws { + let document = MapDocument(text: "Node A (10, 20)\nNode B (30, 40)") + + await MainActor.run { + let image = document.exportAsImage(withEvolution: .general) + #expect(image != nil) + #expect(image!.size.width > 0) + #expect(image!.size.height > 0) + } + } + + @Test func testUTTypeExampleText() async throws { + let utType = UTType.wmap + #expect(utType.identifier == "systems.tranquil.map.wmap") + } +} diff --git a/MapTests/MapParserStrategyTests.swift b/MapTests/MapParserStrategyTests.swift new file mode 100644 index 0000000..c9f92e8 --- /dev/null +++ b/MapTests/MapParserStrategyTests.swift @@ -0,0 +1,163 @@ +// Copyright (C) 2024 Rubén Beltrán del Río + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import CoreGraphics +// You should have received a copy of the GNU General Public License +// along with this program. If not, see https://map.tranquil.systems. +import Testing + +@testable import Map + +struct MapParserStrategyTests { + + @Test func testVertexParserStrategyCanHandle() async throws { + let strategy = VertexParserStrategy() + + #expect(strategy.canHandle(line: "Node (10, 20)")) + #expect(strategy.canHandle(line: "NodeWithDecimals (30.5, 40.2)")) + #expect(strategy.canHandle(line: "Multi Word Node (0, 100)")) + #expect(strategy.canHandle(line: "Node C (50, 60) [square]")) + + #expect(!strategy.canHandle(line: "Node A -> Node B")) + #expect(!strategy.canHandle(line: "[Note] (10, 20) Test")) + #expect(!strategy.canHandle(line: "Invalid line")) + } + + @Test func testVertexParserStrategyHandle() async throws { + let strategy = VertexParserStrategy() + let vertices: [String: Vertex] = [:] + + let (type, object) = strategy.handle(index: 0, line: "Test Node (25, 75)", vertices: vertices) + + #expect(String(describing: type) == String(describing: Vertex.self)) + let vertex = object as! Vertex + #expect(vertex.id == 0) + #expect(vertex.label == "Test Node") + #expect(vertex.position.x == 25.0) + #expect(vertex.position.y == 75.0) + #expect(vertex.shape == .circle) + } + + @Test func testVertexParserStrategyHandleWithShape() async throws { + let strategy = VertexParserStrategy() + let vertices: [String: Vertex] = [:] + + let (type, object) = strategy.handle( + index: 1, line: "Shaped Node (10, 20) [triangle]", vertices: vertices) + + #expect(String(describing: type) == String(describing: Vertex.self)) + let vertex = object as! Vertex + #expect(vertex.shape == .triangle) + } + + @Test func testEdgeParserStrategyCanHandle() async throws { + let strategy = EdgeParserStrategy() + + #expect(strategy.canHandle(line: "Node A -- Node B")) + #expect(strategy.canHandle(line: "Node A -> Node B")) + #expect(strategy.canHandle(line: "Multi Word A -> Multi Word B")) + + #expect(!strategy.canHandle(line: "Node A (10, 20)")) + #expect(!strategy.canHandle(line: "[Note] (10, 20) Test")) + #expect(!strategy.canHandle(line: "Invalid line")) + } + + @Test func testEdgeParserStrategyHandle() async throws { + let strategy = EdgeParserStrategy() + let vertices: [String: Vertex] = [ + "Node A": Vertex(id: 0, label: "Node A", position: CGPoint(x: 10, y: 20)), + "Node B": Vertex(id: 1, label: "Node B", position: CGPoint(x: 30, y: 40)), + ] + + let (type, object) = strategy.handle(index: 2, line: "Node A -> Node B", vertices: vertices) + + #expect(String(describing: type) == String(describing: MapEdge.self)) + let edge = object as! MapEdge + #expect(edge.id == 2) + #expect(edge.origin.x == 10.0) + #expect(edge.origin.y == 20.0) + #expect(edge.destination.x == 30.0) + #expect(edge.destination.y == 40.0) + #expect(edge.arrowhead == true) + } + + @Test func testEdgeParserStrategyHandleWithMissingVertex() async throws { + let strategy = EdgeParserStrategy() + let vertices: [String: Vertex] = [ + "Node A": Vertex(id: 0, label: "Node A", position: CGPoint(x: 10, y: 20)) + ] + + let (type, object) = strategy.handle(index: 2, line: "Node A -> Node B", vertices: vertices) + + #expect(String(describing: type) == String(describing: NSObject.self)) + #expect(object is NSObject) + } + + @Test func testNoteParserStrategyCanHandle() async throws { + let strategy = NoteParserStrategy() + + #expect(strategy.canHandle(line: "[Note] (10, 20) Test note")) + #expect(strategy.canHandle(line: "[Note] (50.5, 60.2) Multi word note")) + + #expect(!strategy.canHandle(line: "Node A (10, 20)")) + #expect(!strategy.canHandle(line: "Node A -> Node B")) + #expect(!strategy.canHandle(line: "Invalid line")) + } + + @Test func testBlockerParserStrategyCanHandle() async throws { + let strategy = BlockerParserStrategy() + + #expect(strategy.canHandle(line: "[Blocker] Node A")) + #expect(strategy.canHandle(line: "[Blocker] Multi Word Node")) + + #expect(!strategy.canHandle(line: "Node A (10, 20)")) + #expect(!strategy.canHandle(line: "[Note] (10, 20) Test")) + #expect(!strategy.canHandle(line: "Invalid line")) + } + + @Test func testOpportunityParserStrategyCanHandle() async throws { + let strategy = OpportunityParserStrategy() + + #expect(strategy.canHandle(line: "[Evolution] Node A +15")) + #expect(strategy.canHandle(line: "[Evolution] Node B -10")) + #expect(strategy.canHandle(line: "[Evolution] Multi Word Node +5.5")) + + #expect(!strategy.canHandle(line: "Node A (10, 20)")) + #expect(!strategy.canHandle(line: "[Blocker] Node A")) + #expect(!strategy.canHandle(line: "Invalid line")) + } + + @Test func testStageParserStrategyCanHandle() async throws { + let strategy = StageParserStrategy() + + #expect(strategy.canHandle(line: "[I] 25")) + #expect(strategy.canHandle(line: "[II] 50.5")) + #expect(strategy.canHandle(line: "[III] 75")) + + #expect(!strategy.canHandle(line: "[IV] 100")) + #expect(!strategy.canHandle(line: "Node A (10, 20)")) + #expect(!strategy.canHandle(line: "Invalid line")) + } + + @Test func testGroupParserStrategyCanHandle() async throws { + let strategy = GroupParserStrategy() + + #expect(strategy.canHandle(line: "[Group] Node A, Node B")) + #expect(strategy.canHandle(line: "[Group] Node A, Node B, Node C")) + #expect(strategy.canHandle(line: "[Group] Multi Word A, Multi Word B")) + + #expect(!strategy.canHandle(line: "Node A (10, 20)")) + #expect(!strategy.canHandle(line: "[Note] (10, 20) Test")) + #expect(!strategy.canHandle(line: "Invalid line")) + } + +} diff --git a/MapTests/MapTests.swift b/MapTests/MapTests.swift index 5a8f781..0dbf63b 100644 --- a/MapTests/MapTests.swift +++ b/MapTests/MapTests.swift @@ -1,11 +1,169 @@ +import CoreGraphics import Testing @testable import Map struct MapTests { - @Test func testExample() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. + @Test func testMapParserWithEmptyInput() async throws { + let result = MapParser.parse(content: "") + #expect(result.vertices.isEmpty) + #expect(result.edges.isEmpty) + #expect(result.notes.isEmpty) + #expect(result.blockers.isEmpty) + #expect(result.opportunities.isEmpty) + #expect(result.groups.isEmpty) + } + + @Test func testMapParserWithSingleVertex() async throws { + let content = "Node A (10, 20)" + let result = MapParser.parse(content: content) + + #expect(result.vertices.count == 1) + let vertex = result.vertices.first! + #expect(vertex.label == "Node A") + #expect(vertex.position.x == 10.0) + #expect(vertex.position.y == 20.0) + #expect(vertex.shape == .circle) + } + + @Test func testMapParserWithVertexWithShape() async throws { + let content = "Node B (30, 40) [square]" + let result = MapParser.parse(content: content) + + #expect(result.vertices.count == 1) + let vertex = result.vertices.first! + #expect(vertex.label == "Node B") + #expect(vertex.position.x == 30.0) + #expect(vertex.position.y == 40.0) + #expect(vertex.shape == .square) + } + + @Test func testMapParserWithEdge() async throws { + let content = """ + Node A (10, 20) + Node B (30, 40) + Node A -- Node B + """ + let result = MapParser.parse(content: content) + + #expect(result.vertices.count == 2) + #expect(result.edges.count == 1) + let edge = result.edges.first! + #expect(edge.origin.x == 10.0) + #expect(edge.origin.y == 20.0) + #expect(edge.destination.x == 30.0) + #expect(edge.destination.y == 40.0) + #expect(edge.arrowhead == false) + } + + @Test func testMapParserWithArrowEdge() async throws { + let content = """ + Node A (10, 20) + Node B (30, 40) + Node A -> Node B + """ + let result = MapParser.parse(content: content) + + #expect(result.edges.count == 1) + let edge = result.edges.first! + #expect(edge.arrowhead == true) + } + + @Test func testMapParserWithNote() async throws { + let content = "[Note] (50, 60) This is a test note" + let result = MapParser.parse(content: content) + + #expect(result.notes.count == 1) + let note = result.notes.first! + #expect(note.position.x == 50.0) + #expect(note.position.y == 60.0) + #expect(note.text == "This is a test note") + } + + @Test func testMapParserWithBlocker() async throws { + let content = """ + Node A (10, 20) + [Blocker] Node A + """ + let result = MapParser.parse(content: content) + + #expect(result.blockers.count == 1) + let blocker = result.blockers.first! + #expect(blocker.position.x == 10.0) + #expect(blocker.position.y == 20.0) + } + + @Test func testMapParserWithOpportunity() async throws { + let content = """ + Node A (10, 20) + [Evolution] Node A +15 + """ + let result = MapParser.parse(content: content) + + #expect(result.opportunities.count == 1) + let opportunity = result.opportunities.first! + #expect(opportunity.origin.x == 10.0) + #expect(opportunity.origin.y == 20.0) + #expect(opportunity.destination.x == 25.0) + #expect(opportunity.destination.y == 20.0) + } + + @Test func testMapParserWithGroup() async throws { + let content = """ + Node A (10, 20) + Node B (30, 40) + Node C (50, 60) + [Group] Node A, Node B, Node C + """ + let result = MapParser.parse(content: content) + + #expect(result.groups.count == 1) + let group = result.groups.first! + #expect(group.count == 3) + #expect(group.map { $0.label }.contains("Node A")) + #expect(group.map { $0.label }.contains("Node B")) + #expect(group.map { $0.label }.contains("Node C")) + } + + @Test func testMapParserWithStages() async throws { + let content = """ + [I] 15 + [II] 35 + [III] 80 + """ + let result = MapParser.parse(content: content) + + #expect(result.stages.count == 3) + #expect(result.stages[0] == 15.0) + #expect(result.stages[1] == 35.0) + #expect(result.stages[2] == 80.0) + } + + @Test func testMapParserWithComplexMap() async throws { + let content = """ + Node A (10, 20) + Node B (30, 40) [square] + Node C (50, 60) + Node A -> Node B + Node B -- Node C + [Note] (70, 80) Complex map example + [Blocker] Node A + [Evolution] Node B +10 + [Group] Node A, Node C + [I] 25 + [II] 75 + """ + let result = MapParser.parse(content: content) + + #expect(result.vertices.count == 3) + #expect(result.edges.count == 2) + #expect(result.notes.count == 1) + #expect(result.blockers.count == 1) + #expect(result.opportunities.count == 1) + #expect(result.groups.count == 1) + #expect(result.stages[0] == 25.0) + #expect(result.stages[1] == 75.0) } } diff --git a/MapTests/StageTests.swift b/MapTests/StageTests.swift new file mode 100644 index 0000000..05812fe --- /dev/null +++ b/MapTests/StageTests.swift @@ -0,0 +1,119 @@ +// Copyright (C) 2024 Rubén Beltrán del Río + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see https://map.tranquil.systems. +import Testing + +@testable import Map + +struct StageTests { + + @Test func testStageTypesGrouping() async throws { + #expect(StageType.types.count == 4) + #expect(StageType.types.contains(.general)) + #expect(StageType.types.contains(.practice)) + #expect(StageType.types.contains(.data)) + #expect(StageType.types.contains(.knowledge)) + + #expect(StageType.characteristics.count == 3) + #expect(StageType.characteristics.contains(.ubiquity)) + #expect(StageType.characteristics.contains(.certainty)) + #expect(StageType.characteristics.contains(.publicationTypes)) + + #expect(StageType.properties.count == 12) + #expect(StageType.properties.contains(.market)) + #expect(StageType.properties.contains(.efficiency)) + + #expect(StageType.custom.count == 1) + #expect(StageType.custom.contains(.behavior)) + } + + @Test func testStageTypeIdentifiable() async throws { + #expect(StageType.general.id == "general") + #expect(StageType.practice.id == "practice") + #expect(StageType.behavior.id == "behavior") + } + + @Test func testStageCreationGeneral() async throws { + let stage = Stage.stages(.general) + #expect(stage.i == "Genesis") + #expect(stage.ii == "Custom") + #expect(stage.iii == "Product (+rental)") + #expect(stage.iv == "Commodity (+utility)") + } + + @Test func testStageCreationPractice() async throws { + let stage = Stage.stages(.practice) + #expect(stage.i == "Novel") + #expect(stage.ii == "Emerging") + #expect(stage.iii == "Good") + #expect(stage.iv == "Best") + } + + @Test func testStageCreationData() async throws { + let stage = Stage.stages(.data) + #expect(stage.i == "Unmodelled") + #expect(stage.ii == "Divergent") + #expect(stage.iii == "Convergent") + #expect(stage.iv == "Modelled") + } + + @Test func testStageCreationKnowledge() async throws { + let stage = Stage.stages(.knowledge) + #expect(stage.i == "Concept") + #expect(stage.ii == "Hypothesis") + #expect(stage.iii == "Theory") + #expect(stage.iv == "Accepted") + } + + @Test func testStageCreationBehavior() async throws { + let stage = Stage.stages(.behavior) + #expect(stage.i == "Uncertain when to use") + #expect(stage.ii == "Learning when to use") + #expect(stage.iii == "Learning through use") + #expect(stage.iv == "Known / common usage") + } + + @Test func testStageTitleGeneral() async throws { + let title = Stage.title(.general) + #expect(title == "Activities") + } + + @Test func testStageTitlePractice() async throws { + let title = Stage.title(.practice) + #expect(title == "Practice") + } + + @Test func testStageTitleBehavior() async throws { + let title = Stage.title(.behavior) + #expect(title == "Behavior") + } + + @Test func testAllStageTypesHaveTitles() async throws { + for stageType in StageType.allCases { + let title = Stage.title(stageType) + #expect(!title.isEmpty) + } + } + + @Test func testAllStageTypesHaveStages() async throws { + for stageType in StageType.allCases { + let stage = Stage.stages(stageType) + #expect(!stage.i.isEmpty) + #expect(!stage.ii.isEmpty) + #expect(!stage.iii.isEmpty) + #expect(!stage.iv.isEmpty) + } + } + +} diff --git a/MapUITests/MapUITests.swift b/MapUITests/MapUITests.swift index 2f6c35d..540d928 100644 --- a/MapUITests/MapUITests.swift +++ b/MapUITests/MapUITests.swift @@ -3,34 +3,287 @@ import XCTest final class MapUITests: XCTestCase { override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. } override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. + Task { @MainActor in + // Close any test documents we may have created to avoid leaving them open + let app = XCUIApplication() + if app.state == .runningForeground { + // Close all windows except the first one (if any existed before tests) + let windowCount = app.windows.count + if windowCount > 1 { + for _ in 1..<windowCount { + app.typeKey("w", modifierFlags: .command) + } + } + } + } + } + + @MainActor + private func createNewDocument(app: XCUIApplication) { + // Create a new document to ensure we start with a clean slate + app.typeKey("n", modifierFlags: .command) + + // Wait for the new document window to appear + let newWindow = app.windows.element(boundBy: app.windows.count - 1) + XCTAssertTrue(newWindow.waitForExistence(timeout: 3.0)) } @MainActor - func testExample() throws { - // UI tests must launch the application that they test. + func testAppLaunchesSuccessfully() throws { let app = XCUIApplication() app.launch() - // Use XCTAssert and related functions to verify your tests produce the correct results. + // Create new document to avoid interfering with existing files + createNewDocument(app: app) + + // Verify the app window appears + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 5.0)) + + // Verify the text editor is present + XCTAssertTrue(app.textViews.firstMatch.exists) + + // Verify the evolution picker is present + XCTAssertTrue(app.popUpButtons.firstMatch.exists) + } + + @MainActor + func testMapEditorTextInput() throws { + let app = XCUIApplication() + app.launch() + + // Create new document to avoid interfering with existing files + createNewDocument(app: app) + + let textView = app.textViews.firstMatch + XCTAssertTrue(textView.waitForExistence(timeout: 5.0)) + + // Clear existing text and input new map content + textView.click() + textView.typeKey("a", modifierFlags: .command) // Select all + textView.typeText("Node A (10, 20)\nNode B (30, 40)\nNode A -> Node B") + + // Verify the text was entered + XCTAssertTrue(textView.value as? String != nil) + } + + @MainActor + func testEvolutionPickerFunctionality() throws { + let app = XCUIApplication() + app.launch() + + // Create new document to avoid interfering with existing files + createNewDocument(app: app) + + // Look specifically for the evolution picker in the toolbar, not just any popup + let toolbar = app.toolbars.firstMatch + XCTAssertTrue(toolbar.waitForExistence(timeout: 5.0)) + + let evolutionPicker = toolbar.popUpButtons.firstMatch + XCTAssertTrue(evolutionPicker.waitForExistence(timeout: 5.0)) + + // Store the original selection to restore it later + let originalSelection = evolutionPicker.value as? String + + // Click the evolution picker + evolutionPicker.click() + + // Verify dropdown menu appears with options + let menu = app.menus.firstMatch + XCTAssertTrue(menu.waitForExistence(timeout: 2.0)) + + // Check that there are menu items + XCTAssertTrue(menu.menuItems.count > 0) + + // Test selecting a different option temporarily + if menu.menuItems["Practice"].exists { + menu.menuItems["Practice"].click() + + // Verify the selection changed + XCTAssertEqual(evolutionPicker.value as? String, "Practice") + + // Restore original selection if we had one and it's different + if let original = originalSelection, original != "Practice" { + evolutionPicker.click() + let restoreMenu = app.menus.firstMatch + if restoreMenu.waitForExistence(timeout: 2.0) && restoreMenu.menuItems[original].exists { + restoreMenu.menuItems[original].click() + } + } + } else { + // If Practice doesn't exist, just close the menu + app.typeKey(XCUIKeyboardKey.escape, modifierFlags: []) + } + } + + @MainActor + func testZoomSliderFunctionality() throws { + let app = XCUIApplication() + app.launch() + + // Create new document to avoid interfering with existing files + createNewDocument(app: app) + + // Find the zoom slider + let slider = app.sliders.firstMatch + XCTAssertTrue(slider.waitForExistence(timeout: 5.0)) + + // Get initial value and verify slider exists + XCTAssertTrue(slider.exists, "Slider should exist") + + // Adjust the slider + slider.adjust(toNormalizedSliderPosition: 0.8) + + // For zoom sliders, we can verify the change by checking if the adjustment was successful + // Since macOS sliders sometimes don't expose their values properly in UI tests, + // we'll just verify that the slider can be interacted with + XCTAssertTrue(slider.exists, "Slider should still exist after adjustment") + } + + @MainActor + func testMenuBarCommands() throws { + let app = XCUIApplication() + app.launch() + + // Create new document first to have a window available + createNewDocument(app: app) + + // Test File menu + let fileMenu = app.menuBars.menuBarItems["File"] + if fileMenu.exists { + fileMenu.click() + + // Look for New Document command + let newDocumentItem = app.menus.menuItems["New Document"] + if newDocumentItem.exists { + XCTAssertTrue(newDocumentItem.isEnabled) + } + + // Press Escape to close menu instead of clicking on window + app.typeKey(XCUIKeyboardKey.escape, modifierFlags: []) + } + } + + @MainActor + func testKeyboardShortcuts() throws { + let app = XCUIApplication() + app.launch() + + // Create new document to avoid interfering with existing files + createNewDocument(app: app) + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5.0)) + + // Test zoom in shortcut (Cmd+Plus) + window.typeKey("+", modifierFlags: .command) + + // Test zoom out shortcut (Cmd+Minus) + window.typeKey("-", modifierFlags: .command) + + // Test search shortcut (Cmd+F) + window.typeKey("f", modifierFlags: .command) + + // Check if search bar appears + let searchField = app.textFields["Search"] + if searchField.waitForExistence(timeout: 2.0) { + XCTAssertTrue(searchField.exists) + + // Type in search field + searchField.typeText("Node") + + // Press Escape to close search + searchField.typeKey(XCUIKeyboardKey.escape, modifierFlags: []) + } + } + + @MainActor + func testSplitViewResizing() throws { + let app = XCUIApplication() + app.launch() + + // Create new document to avoid interfering with existing files + createNewDocument(app: app) + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5.0)) + + // Find split view divider + let splitViews = app.splitGroups + if splitViews.count > 0 { + let splitView = splitViews.firstMatch + XCTAssertTrue(splitView.exists) + + // Test that we can interact with the split view + // Note: Actual dragging of split view dividers is complex in UI tests + // This test mainly verifies the split view structure exists + } + } + + @MainActor + func testDocumentSaveLoad() throws { + let app = XCUIApplication() + app.launch() + + // Create new document to avoid interfering with existing files + createNewDocument(app: app) + + let textView = app.textViews.firstMatch + XCTAssertTrue(textView.waitForExistence(timeout: 5.0)) + + // Enter test content + textView.click() + textView.typeKey("a", modifierFlags: .command) + textView.typeText("Test Map Content\nNode A (10, 20)") + + // Test Save command (Cmd+S) - this will open save dialog for new document + app.typeKey("s", modifierFlags: .command) + + // If save dialog appears, dismiss it to avoid saving test files + let saveDialog = app.sheets.firstMatch + if saveDialog.waitForExistence(timeout: 2.0) { + let cancelButton = saveDialog.buttons["Cancel"] + if cancelButton.exists { + cancelButton.click() + } + } + } + + @MainActor + func testMapRenderingArea() throws { + let app = XCUIApplication() + app.launch() + + // Create new document to avoid interfering with existing files + createNewDocument(app: app) + + // Add some map content + let textView = app.textViews.firstMatch + XCTAssertTrue(textView.waitForExistence(timeout: 5.0)) + + textView.click() + textView.typeKey("a", modifierFlags: .command) + textView.typeText("Node A (10, 20)\nNode B (30, 40)\nNode A -> Node B") + + // Verify the map rendering area exists + let scrollViews = app.scrollViews + XCTAssertTrue(scrollViews.count > 0) + + // The map rendering should be in a scroll view + let mapScrollView = scrollViews.element(boundBy: scrollViews.count - 1) + XCTAssertTrue(mapScrollView.exists) } @MainActor func testLaunchPerformance() throws { if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { - // This measures how long it takes to launch your application. measure(metrics: [XCTApplicationLaunchMetric()]) { XCUIApplication().launch() } } } + } diff --git a/MapUITests/SearchUITests.swift b/MapUITests/SearchUITests.swift new file mode 100644 index 0000000..1891678 --- /dev/null +++ b/MapUITests/SearchUITests.swift @@ -0,0 +1,226 @@ +// Copyright (C) 2024 Rubén Beltrán del Río + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see https://map.tranquil.systems. +import XCTest + +final class SearchUITests: XCTestCase { + + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + Task { @MainActor in + // Close any test documents we may have created to avoid leaving them open + let app = XCUIApplication() + if app.state == .runningForeground { + // Close all windows except the first one (if any existed before tests) + let windowCount = app.windows.count + if windowCount > 1 { + for _ in 1..<windowCount { + app.typeKey("w", modifierFlags: .command) + } + } + } + } + } + + @MainActor + private func createNewDocument(app: XCUIApplication) { + // Create a new document to ensure we start with a clean slate + app.typeKey("n", modifierFlags: .command) + + // Wait for the new document window to appear + let newWindow = app.windows.element(boundBy: app.windows.count - 1) + XCTAssertTrue(newWindow.waitForExistence(timeout: 3.0)) + } + + @MainActor + func testSearchBarAppearance() throws { + let app = XCUIApplication() + app.launch() + + // Create new document to avoid interfering with existing files + createNewDocument(app: app) + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5.0)) + + // Trigger search with Cmd+F + window.typeKey("f", modifierFlags: .command) + + // Verify search bar appears (SearchBar uses a TextField with placeholder "Search") + let searchField = app.textFields["Search"] + XCTAssertTrue(searchField.waitForExistence(timeout: 2.0)) + + // Verify search navigation buttons appear - the search bar should contain multiple buttons + // We'll check for the Done button which should be most reliable + let doneButton = app.buttons["Done"] + XCTAssertTrue(doneButton.waitForExistence(timeout: 2.0)) + } + + @MainActor + func testSearchFunctionality() throws { + let app = XCUIApplication() + app.launch() + + // Create new document to avoid interfering with existing files + createNewDocument(app: app) + + // Add test content with searchable terms + let textView = app.textViews.firstMatch + XCTAssertTrue(textView.waitForExistence(timeout: 5.0)) + + textView.click() + textView.typeKey("a", modifierFlags: .command) + textView.typeText("Node A (10, 20)\nNode B (30, 40)\nNode C (50, 60)\nNode A -> Node B") + + // Open search + app.typeKey("f", modifierFlags: .command) + + let searchField = app.textFields["Search"] + XCTAssertTrue(searchField.waitForExistence(timeout: 2.0)) + + // Search for "Node" + searchField.typeText("Node") + + // Verify search results are highlighted (this is visual, hard to test directly) + // We can at least verify the search field contains our text + XCTAssertEqual(searchField.value as? String, "Node") + } + + @MainActor + func testSearchNavigation() throws { + let app = XCUIApplication() + app.launch() + + // Create new document to avoid interfering with existing files + createNewDocument(app: app) + + // Add test content with multiple instances of search term + let textView = app.textViews.firstMatch + XCTAssertTrue(textView.waitForExistence(timeout: 5.0)) + + textView.click() + textView.typeKey("a", modifierFlags: .command) + textView.typeText("Test Test Test\nAnother Test line\nFinal Test here") + + // Open search + app.typeKey("f", modifierFlags: .command) + + let searchField = app.textFields["Search"] + XCTAssertTrue(searchField.waitForExistence(timeout: 2.0)) + + // Search for "Test" + searchField.typeText("Test") + + // Try navigation with chevron buttons + let nextButton = app.buttons.containing(.image, identifier: "chevron.right").firstMatch + let previousButton = app.buttons.containing(.image, identifier: "chevron.left").firstMatch + + if nextButton.waitForExistence(timeout: 2.0) { + nextButton.click() + } + + if previousButton.waitForExistence(timeout: 2.0) { + previousButton.click() + } + } + + @MainActor + func testSearchDismissal() throws { + let app = XCUIApplication() + app.launch() + + // Create new document to avoid interfering with existing files + createNewDocument(app: app) + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5.0)) + + // Open search + window.typeKey("f", modifierFlags: .command) + + let searchField = app.textFields["Search"] + XCTAssertTrue(searchField.waitForExistence(timeout: 2.0)) + + // Type something + searchField.typeText("test") + + // Dismiss with Escape + searchField.typeKey(XCUIKeyboardKey.escape, modifierFlags: []) + + // Verify search bar is dismissed + XCTAssertFalse(searchField.waitForExistence(timeout: 1.0)) + } + + @MainActor + func testSearchWithEmptyResults() throws { + let app = XCUIApplication() + app.launch() + + // Create new document to avoid interfering with existing files + createNewDocument(app: app) + + // Add test content + let textView = app.textViews.firstMatch + XCTAssertTrue(textView.waitForExistence(timeout: 5.0)) + + textView.click() + textView.typeKey("a", modifierFlags: .command) + textView.typeText("Node A (10, 20)\nNode B (30, 40)") + + // Open search + app.typeKey("f", modifierFlags: .command) + + let searchField = app.textFields["Search"] + XCTAssertTrue(searchField.waitForExistence(timeout: 2.0)) + + // Search for something that doesn't exist + searchField.typeText("XYZ123") + + // Verify search field still exists and contains our search term + XCTAssertEqual(searchField.value as? String, "XYZ123") + } + + @MainActor + func testSearchCaseSensitivity() throws { + let app = XCUIApplication() + app.launch() + + // Create new document to avoid interfering with existing files + createNewDocument(app: app) + + // Add test content with mixed case + let textView = app.textViews.firstMatch + XCTAssertTrue(textView.waitForExistence(timeout: 5.0)) + + textView.click() + textView.typeKey("a", modifierFlags: .command) + textView.typeText("Node A (10, 20)\nnode B (30, 40)\nNODE C (50, 60)") + + // Open search + app.typeKey("f", modifierFlags: .command) + + let searchField = app.textFields["Search"] + XCTAssertTrue(searchField.waitForExistence(timeout: 2.0)) + + // Search for "node" (lowercase) + searchField.typeText("node") + + // The search should be case-insensitive based on the implementation + XCTAssertEqual(searchField.value as? String, "node") + } + +} @@ -4,7 +4,11 @@ A wardley mapping tool: Write some text, get a diagram ## Building -Open the project in Xcode and press buithe run button +Open the project in Xcode and press the run button (or `make build`) + +## Building + +Open the project in Xcode and press the test button (or `make test`) ## Formatting and Linting |