diff --git a/.gitignore b/.gitignore index c7dd8a9..415eaec 100644 --- a/.gitignore +++ b/.gitignore @@ -98,6 +98,9 @@ InitTestScene*.unity* # Auto-generated scenes by play mode tests /[Aa]ssets/[Ii]nit[Tt]est[Ss]cene*.unity* +# Screenshots captured by Unity MCP +Assets/Screenshots/ + # OpenCode AI assistant files .sisyphus/ .sisyphus-* diff --git a/.sisyphus/notepads/comprehensive-refactoring/learnings.md b/.sisyphus/notepads/comprehensive-refactoring/learnings.md new file mode 100644 index 0000000..c347a24 --- /dev/null +++ b/.sisyphus/notepads/comprehensive-refactoring/learnings.md @@ -0,0 +1,24 @@ +## Notepad: Learnings + + +- 2026-02-20: ComponentView static sprite/texture generation code can be extracted into `ComponentSymbolGenerator` without behavior change by moving all symbol drawing caches/helpers and keeping only callsites (`GetOrCreateFallbackSprite`, glow sprites, pin dot sprite/radius) in `ComponentView`. + +- `WireRoutingController`: extracted repeated status literals into `StatusWiring`, `StatusWiringMode`, and `StatusReady` constants to satisfy inline-string cleanup. + +- 2026-02-20: Extracted duplicated SI formatting into `CircuitUnitFormatter` (`CircuitCraft.Utils`) and switched `ComponentView.FormatComponentLabel` / `PowerFlowVisualizer` overlay formatting to shared static calls while preserving original thresholds and numeric precision (`0.##`, `0.000`, `0.###`). + +- 2026-02-20: Extracted duplicated UI Toolkit pointer hit-testing from `PlacementController` and `WireRoutingController` into `UIInputHelper` (`CircuitCraft.Utils`) with identical behavior, including `TemplateContainer` exclusion and `GameView` subtree passthrough logic for wiring mode. + +- 2026-02-20: LED/heat glow runtime object ownership can be isolated in a plain helper (`ComponentEffects`) while preserving behavior by keeping `ComponentView` serialized config fields in place and delegating unchanged public facade signatures (`ShowLEDGlow`, `HideLEDGlow`, `ShowResistorHeatGlow`, `HideResistorHeatGlow`) to the helper instance. + +- 2026-02-20: Simulation overlay label lifecycle can be extracted to an `internal sealed` plain helper (`ComponentOverlay`) with constructor-injected parent/sprite/config while preserving `ComponentView` inspector fields and public facade API (`ShowSimulationOverlay(string)`, `HideSimulationOverlay()`) used by `PowerFlowVisualizer`. + +- 2026-02-21: `ServiceRegistry` can gain extensibility without breaking callers by introducing a single `Dictionary` generic core (`Register`, `Resolve`, `Unregister`) and keeping typed `GameManager`/`SimulationManager` properties and overloads as thin wrappers. + +- 2026-02-21: `UIController` palette drag-resize code can be moved unchanged into a plain helper (`PaletteResizer`) by constructor-injecting `ComponentPalette`, `PaletteResizeHandle`, and root `VisualElement`, then delegating registration lifecycle in `OnEnable`/`OnDisable`; pointer capture, width clamping (280-420), and stop-propagation behavior remain intact. + +- 2026-02-21: `UIController` StatusBar per-frame work can be reduced without changing output by wiring discrete updates to `GridCursor.OnPositionChanged` and `CommandHistory.OnHistoryChanged`, keeping only zoom as a lightweight per-frame dirty check when no camera zoom event exists. + +- 2026-02-21: `CircuitUnitFormatterTests` (28 [Test] cases) added to `Assets/10_Scripts/30_Tests/10_Utils/`. Float literal threshold comparisons in `FormatCapacitance`/`FormatInductance` are safe even at very small values (1e-9f, 1e-12f) because C# uses the same float32 literal for both the test input and the formatter's `>=` threshold — identical bit patterns mean equality is guaranteed. Unity MCP was not reachable (port 8080 refused) so tests were verified by manual logic tracing against the formatter implementation. + +- 2026-02-21: asmdef visibility follows assembly boundaries; moving `CircuitUnitFormatter` from `90_Utils` into `10_Core` keeps namespace `CircuitCraft.Utils` while allowing `CircuitCraft.Components` and `CircuitCraft.Tests` to consume it via `CircuitCraft.Core` references (`noEngineReferences: true`, `autoReferenced: true`). diff --git a/Assets/00_Core/10_Settings/SampleSceneProfile.asset b/Assets/00_Core/10_Settings/SampleSceneProfile.asset deleted file mode 100644 index 37e401d..0000000 --- a/Assets/00_Core/10_Settings/SampleSceneProfile.asset +++ /dev/null @@ -1,123 +0,0 @@ -%YAML 1.1 -%TAG !u! tag:unity3d.com,2011: ---- !u!114 &-7893295128165547882 -MonoBehaviour: - m_ObjectHideFlags: 3 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 0b2db86121404754db890f4c8dfe81b2, type: 3} - m_Name: Bloom - m_EditorClassIdentifier: - active: 1 - m_AdvancedMode: 0 - threshold: - m_OverrideState: 1 - m_Value: 1 - min: 0 - intensity: - m_OverrideState: 1 - m_Value: 1 - min: 0 - scatter: - m_OverrideState: 0 - m_Value: 0.7 - min: 0 - max: 1 - clamp: - m_OverrideState: 0 - m_Value: 65472 - min: 0 - tint: - m_OverrideState: 0 - m_Value: {r: 1, g: 1, b: 1, a: 1} - hdr: 0 - showAlpha: 0 - showEyeDropper: 1 - highQualityFiltering: - m_OverrideState: 0 - m_Value: 0 - skipIterations: - m_OverrideState: 0 - m_Value: 1 - min: 0 - max: 16 - dirtTexture: - m_OverrideState: 0 - m_Value: {fileID: 0} - dirtIntensity: - m_OverrideState: 0 - m_Value: 0 - min: 0 ---- !u!114 &-7011558710299706105 -MonoBehaviour: - m_ObjectHideFlags: 3 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 899c54efeace73346a0a16faa3afe726, type: 3} - m_Name: Vignette - m_EditorClassIdentifier: - active: 1 - m_AdvancedMode: 0 - color: - m_OverrideState: 0 - m_Value: {r: 0, g: 0, b: 0, a: 1} - hdr: 0 - showAlpha: 0 - showEyeDropper: 1 - center: - m_OverrideState: 0 - m_Value: {x: 0.5, y: 0.5} - intensity: - m_OverrideState: 1 - m_Value: 0.25 - min: 0 - max: 1 - smoothness: - m_OverrideState: 1 - m_Value: 0.4 - min: 0.01 - max: 1 - rounded: - m_OverrideState: 0 - m_Value: 0 ---- !u!114 &11400000 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: d7fd9488000d3734a9e00ee676215985, type: 3} - m_Name: SampleSceneProfile - m_EditorClassIdentifier: - components: - - {fileID: 849379129802519247} - - {fileID: -7893295128165547882} - - {fileID: -7011558710299706105} ---- !u!114 &849379129802519247 -MonoBehaviour: - m_ObjectHideFlags: 3 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 97c23e3b12dc18c42a140437e53d3951, type: 3} - m_Name: Tonemapping - m_EditorClassIdentifier: - active: 1 - m_AdvancedMode: 0 - mode: - m_OverrideState: 1 - m_Value: 1 diff --git a/Assets/00_Core/Readme.asset b/Assets/00_Core/Readme.asset deleted file mode 100644 index 77c2f83..0000000 --- a/Assets/00_Core/Readme.asset +++ /dev/null @@ -1,34 +0,0 @@ -%YAML 1.1 -%TAG !u! tag:unity3d.com,2011: ---- !u!114 &11400000 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: fcf7219bab7fe46a1ad266029b2fee19, type: 3} - m_Name: Readme - m_EditorClassIdentifier: - icon: {fileID: 2800000, guid: 727a75301c3d24613a3ebcec4a24c2c8, type: 3} - title: URP Empty Template - sections: - - heading: Welcome to the Universal Render Pipeline - text: This template includes the settings and assets you need to start creating with the Universal Render Pipeline. - linkText: - url: - - heading: URP Documentation - text: - linkText: Read more about URP - url: https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@latest - - heading: Forums - text: - linkText: Get answers and support - url: https://forum.unity.com/forums/universal-render-pipeline.383/ - - heading: Report bugs - text: - linkText: Submit a report - url: https://unity3d.com/unity/qa/bug-reporting - loadedLayout: 1 diff --git a/Assets/00_Core/TutorialInfo/Icons.meta b/Assets/00_Core/TutorialInfo/Icons.meta deleted file mode 100644 index 1d19fb9..0000000 --- a/Assets/00_Core/TutorialInfo/Icons.meta +++ /dev/null @@ -1,9 +0,0 @@ -fileFormatVersion: 2 -guid: 8a0c9218a650547d98138cd835033977 -folderAsset: yes -timeCreated: 1484670163 -licenseType: Store -DefaultImporter: - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/00_Core/TutorialInfo/Icons/URP.png b/Assets/00_Core/TutorialInfo/Icons/URP.png deleted file mode 100644 index 6194a80..0000000 Binary files a/Assets/00_Core/TutorialInfo/Icons/URP.png and /dev/null differ diff --git a/Assets/00_Core/TutorialInfo/Layout.wlt b/Assets/00_Core/TutorialInfo/Layout.wlt deleted file mode 100644 index 7b50a25..0000000 --- a/Assets/00_Core/TutorialInfo/Layout.wlt +++ /dev/null @@ -1,654 +0,0 @@ -%YAML 1.1 -%TAG !u! tag:unity3d.com,2011: ---- !u!114 &1 -MonoBehaviour: - m_ObjectHideFlags: 52 - m_PrefabParentObject: {fileID: 0} - m_PrefabInternal: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 1 - m_Script: {fileID: 12004, guid: 0000000000000000e000000000000000, type: 0} - m_Name: - m_EditorClassIdentifier: - m_PixelRect: - serializedVersion: 2 - x: 0 - y: 45 - width: 1666 - height: 958 - m_ShowMode: 4 - m_Title: - m_RootView: {fileID: 6} - m_MinSize: {x: 950, y: 542} - m_MaxSize: {x: 10000, y: 10000} ---- !u!114 &2 -MonoBehaviour: - m_ObjectHideFlags: 52 - m_PrefabParentObject: {fileID: 0} - m_PrefabInternal: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 1 - m_Script: {fileID: 12006, guid: 0000000000000000e000000000000000, type: 0} - m_Name: - m_EditorClassIdentifier: - m_Children: [] - m_Position: - serializedVersion: 2 - x: 0 - y: 466 - width: 290 - height: 442 - m_MinSize: {x: 234, y: 271} - m_MaxSize: {x: 10004, y: 10021} - m_ActualView: {fileID: 14} - m_Panes: - - {fileID: 14} - m_Selected: 0 - m_LastSelected: 0 ---- !u!114 &3 -MonoBehaviour: - m_ObjectHideFlags: 52 - m_PrefabParentObject: {fileID: 0} - m_PrefabInternal: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 1 - m_Script: {fileID: 12010, guid: 0000000000000000e000000000000000, type: 0} - m_Name: - m_EditorClassIdentifier: - m_Children: - - {fileID: 4} - - {fileID: 2} - m_Position: - serializedVersion: 2 - x: 973 - y: 0 - width: 290 - height: 908 - m_MinSize: {x: 234, y: 492} - m_MaxSize: {x: 10004, y: 14042} - vertical: 1 - controlID: 226 ---- !u!114 &4 -MonoBehaviour: - m_ObjectHideFlags: 52 - m_PrefabParentObject: {fileID: 0} - m_PrefabInternal: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 1 - m_Script: {fileID: 12006, guid: 0000000000000000e000000000000000, type: 0} - m_Name: - m_EditorClassIdentifier: - m_Children: [] - m_Position: - serializedVersion: 2 - x: 0 - y: 0 - width: 290 - height: 466 - m_MinSize: {x: 204, y: 221} - m_MaxSize: {x: 4004, y: 4021} - m_ActualView: {fileID: 17} - m_Panes: - - {fileID: 17} - m_Selected: 0 - m_LastSelected: 0 ---- !u!114 &5 -MonoBehaviour: - m_ObjectHideFlags: 52 - m_PrefabParentObject: {fileID: 0} - m_PrefabInternal: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 1 - m_Script: {fileID: 12006, guid: 0000000000000000e000000000000000, type: 0} - m_Name: - m_EditorClassIdentifier: - m_Children: [] - m_Position: - serializedVersion: 2 - x: 0 - y: 466 - width: 973 - height: 442 - m_MinSize: {x: 202, y: 221} - m_MaxSize: {x: 4002, y: 4021} - m_ActualView: {fileID: 15} - m_Panes: - - {fileID: 15} - m_Selected: 0 - m_LastSelected: 0 ---- !u!114 &6 -MonoBehaviour: - m_ObjectHideFlags: 52 - m_PrefabParentObject: {fileID: 0} - m_PrefabInternal: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 1 - m_Script: {fileID: 12008, guid: 0000000000000000e000000000000000, type: 0} - m_Name: - m_EditorClassIdentifier: - m_Children: - - {fileID: 7} - - {fileID: 8} - - {fileID: 9} - m_Position: - serializedVersion: 2 - x: 0 - y: 0 - width: 1666 - height: 958 - m_MinSize: {x: 950, y: 542} - m_MaxSize: {x: 10000, y: 10000} ---- !u!114 &7 -MonoBehaviour: - m_ObjectHideFlags: 52 - m_PrefabParentObject: {fileID: 0} - m_PrefabInternal: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 1 - m_Script: {fileID: 12011, guid: 0000000000000000e000000000000000, type: 0} - m_Name: - m_EditorClassIdentifier: - m_Children: [] - m_Position: - serializedVersion: 2 - x: 0 - y: 0 - width: 1666 - height: 30 - m_MinSize: {x: 0, y: 0} - m_MaxSize: {x: 0, y: 0} - m_LastLoadedLayoutName: Tutorial ---- !u!114 &8 -MonoBehaviour: - m_ObjectHideFlags: 52 - m_PrefabParentObject: {fileID: 0} - m_PrefabInternal: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 1 - m_Script: {fileID: 12010, guid: 0000000000000000e000000000000000, type: 0} - m_Name: - m_EditorClassIdentifier: - m_Children: - - {fileID: 10} - - {fileID: 3} - - {fileID: 11} - m_Position: - serializedVersion: 2 - x: 0 - y: 30 - width: 1666 - height: 908 - m_MinSize: {x: 713, y: 492} - m_MaxSize: {x: 18008, y: 14042} - vertical: 0 - controlID: 74 ---- !u!114 &9 -MonoBehaviour: - m_ObjectHideFlags: 52 - m_PrefabParentObject: {fileID: 0} - m_PrefabInternal: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 1 - m_Script: {fileID: 12042, guid: 0000000000000000e000000000000000, type: 0} - m_Name: - m_EditorClassIdentifier: - m_Children: [] - m_Position: - serializedVersion: 2 - x: 0 - y: 938 - width: 1666 - height: 20 - m_MinSize: {x: 0, y: 0} - m_MaxSize: {x: 0, y: 0} ---- !u!114 &10 -MonoBehaviour: - m_ObjectHideFlags: 52 - m_PrefabParentObject: {fileID: 0} - m_PrefabInternal: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 1 - m_Script: {fileID: 12010, guid: 0000000000000000e000000000000000, type: 0} - m_Name: - m_EditorClassIdentifier: - m_Children: - - {fileID: 12} - - {fileID: 5} - m_Position: - serializedVersion: 2 - x: 0 - y: 0 - width: 973 - height: 908 - m_MinSize: {x: 202, y: 442} - m_MaxSize: {x: 4002, y: 8042} - vertical: 1 - controlID: 75 ---- !u!114 &11 -MonoBehaviour: - m_ObjectHideFlags: 52 - m_PrefabParentObject: {fileID: 0} - m_PrefabInternal: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 1 - m_Script: {fileID: 12006, guid: 0000000000000000e000000000000000, type: 0} - m_Name: - m_EditorClassIdentifier: - m_Children: [] - m_Position: - serializedVersion: 2 - x: 1263 - y: 0 - width: 403 - height: 908 - m_MinSize: {x: 277, y: 71} - m_MaxSize: {x: 4002, y: 4021} - m_ActualView: {fileID: 13} - m_Panes: - - {fileID: 13} - m_Selected: 0 - m_LastSelected: 0 ---- !u!114 &12 -MonoBehaviour: - m_ObjectHideFlags: 52 - m_PrefabParentObject: {fileID: 0} - m_PrefabInternal: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 1 - m_Script: {fileID: 12006, guid: 0000000000000000e000000000000000, type: 0} - m_Name: - m_EditorClassIdentifier: - m_Children: [] - m_Position: - serializedVersion: 2 - x: 0 - y: 0 - width: 973 - height: 466 - m_MinSize: {x: 202, y: 221} - m_MaxSize: {x: 4002, y: 4021} - m_ActualView: {fileID: 16} - m_Panes: - - {fileID: 16} - m_Selected: 0 - m_LastSelected: 0 ---- !u!114 &13 -MonoBehaviour: - m_ObjectHideFlags: 52 - m_PrefabParentObject: {fileID: 0} - m_PrefabInternal: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 1 - m_Script: {fileID: 12019, guid: 0000000000000000e000000000000000, type: 0} - m_Name: - m_EditorClassIdentifier: - m_AutoRepaintOnSceneChange: 0 - m_MinSize: {x: 275, y: 50} - m_MaxSize: {x: 4000, y: 4000} - m_TitleContent: - m_Text: Inspector - m_Image: {fileID: -6905738622615590433, guid: 0000000000000000d000000000000000, - type: 0} - m_Tooltip: - m_DepthBufferBits: 0 - m_Pos: - serializedVersion: 2 - x: 2 - y: 19 - width: 401 - height: 887 - m_ScrollPosition: {x: 0, y: 0} - m_InspectorMode: 0 - m_PreviewResizer: - m_CachedPref: -160 - m_ControlHash: -371814159 - m_PrefName: Preview_InspectorPreview - m_PreviewWindow: {fileID: 0} ---- !u!114 &14 -MonoBehaviour: - m_ObjectHideFlags: 52 - m_PrefabParentObject: {fileID: 0} - m_PrefabInternal: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 1 - m_Script: {fileID: 12014, guid: 0000000000000000e000000000000000, type: 0} - m_Name: - m_EditorClassIdentifier: - m_AutoRepaintOnSceneChange: 0 - m_MinSize: {x: 230, y: 250} - m_MaxSize: {x: 10000, y: 10000} - m_TitleContent: - m_Text: Project - m_Image: {fileID: -7501376956915960154, guid: 0000000000000000d000000000000000, - type: 0} - m_Tooltip: - m_DepthBufferBits: 0 - m_Pos: - serializedVersion: 2 - x: 2 - y: 19 - width: 286 - height: 421 - m_SearchFilter: - m_NameFilter: - m_ClassNames: [] - m_AssetLabels: [] - m_AssetBundleNames: [] - m_VersionControlStates: [] - m_ReferencingInstanceIDs: - m_ScenePaths: [] - m_ShowAllHits: 0 - m_SearchArea: 0 - m_Folders: - - Assets - m_ViewMode: 0 - m_StartGridSize: 64 - m_LastFolders: - - Assets - m_LastFoldersGridSize: -1 - m_LastProjectPath: /Users/danielbrauer/Unity Projects/New Unity Project 47 - m_IsLocked: 0 - m_FolderTreeState: - scrollPos: {x: 0, y: 0} - m_SelectedIDs: ee240000 - m_LastClickedID: 9454 - m_ExpandedIDs: ee24000000ca9a3bffffff7f - m_RenameOverlay: - m_UserAcceptedRename: 0 - m_Name: - m_OriginalName: - m_EditFieldRect: - serializedVersion: 2 - x: 0 - y: 0 - width: 0 - height: 0 - m_UserData: 0 - m_IsWaitingForDelay: 0 - m_IsRenaming: 0 - m_OriginalEventType: 11 - m_IsRenamingFilename: 1 - m_ClientGUIView: {fileID: 0} - m_SearchString: - m_CreateAssetUtility: - m_EndAction: {fileID: 0} - m_InstanceID: 0 - m_Path: - m_Icon: {fileID: 0} - m_ResourceFile: - m_AssetTreeState: - scrollPos: {x: 0, y: 0} - m_SelectedIDs: 68fbffff - m_LastClickedID: 0 - m_ExpandedIDs: ee240000 - m_RenameOverlay: - m_UserAcceptedRename: 0 - m_Name: - m_OriginalName: - m_EditFieldRect: - serializedVersion: 2 - x: 0 - y: 0 - width: 0 - height: 0 - m_UserData: 0 - m_IsWaitingForDelay: 0 - m_IsRenaming: 0 - m_OriginalEventType: 11 - m_IsRenamingFilename: 1 - m_ClientGUIView: {fileID: 0} - m_SearchString: - m_CreateAssetUtility: - m_EndAction: {fileID: 0} - m_InstanceID: 0 - m_Path: - m_Icon: {fileID: 0} - m_ResourceFile: - m_ListAreaState: - m_SelectedInstanceIDs: 68fbffff - m_LastClickedInstanceID: -1176 - m_HadKeyboardFocusLastEvent: 0 - m_ExpandedInstanceIDs: c6230000 - m_RenameOverlay: - m_UserAcceptedRename: 0 - m_Name: - m_OriginalName: - m_EditFieldRect: - serializedVersion: 2 - x: 0 - y: 0 - width: 0 - height: 0 - m_UserData: 0 - m_IsWaitingForDelay: 0 - m_IsRenaming: 0 - m_OriginalEventType: 11 - m_IsRenamingFilename: 1 - m_ClientGUIView: {fileID: 0} - m_CreateAssetUtility: - m_EndAction: {fileID: 0} - m_InstanceID: 0 - m_Path: - m_Icon: {fileID: 0} - m_ResourceFile: - m_NewAssetIndexInList: -1 - m_ScrollPosition: {x: 0, y: 0} - m_GridSize: 64 - m_DirectoriesAreaWidth: 110 ---- !u!114 &15 -MonoBehaviour: - m_ObjectHideFlags: 52 - m_PrefabParentObject: {fileID: 0} - m_PrefabInternal: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 1 - m_Script: {fileID: 12015, guid: 0000000000000000e000000000000000, type: 0} - m_Name: - m_EditorClassIdentifier: - m_AutoRepaintOnSceneChange: 1 - m_MinSize: {x: 200, y: 200} - m_MaxSize: {x: 4000, y: 4000} - m_TitleContent: - m_Text: Game - m_Image: {fileID: -2087823869225018852, guid: 0000000000000000d000000000000000, - type: 0} - m_Tooltip: - m_DepthBufferBits: 32 - m_Pos: - serializedVersion: 2 - x: 0 - y: 19 - width: 971 - height: 421 - m_MaximizeOnPlay: 0 - m_Gizmos: 0 - m_Stats: 0 - m_SelectedSizes: 00000000000000000000000000000000000000000000000000000000000000000000000000000000 - m_TargetDisplay: 0 - m_ZoomArea: - m_HRangeLocked: 0 - m_VRangeLocked: 0 - m_HBaseRangeMin: -242.75 - m_HBaseRangeMax: 242.75 - m_VBaseRangeMin: -101 - m_VBaseRangeMax: 101 - m_HAllowExceedBaseRangeMin: 1 - m_HAllowExceedBaseRangeMax: 1 - m_VAllowExceedBaseRangeMin: 1 - m_VAllowExceedBaseRangeMax: 1 - m_ScaleWithWindow: 0 - m_HSlider: 0 - m_VSlider: 0 - m_IgnoreScrollWheelUntilClicked: 0 - m_EnableMouseInput: 1 - m_EnableSliderZoom: 0 - m_UniformScale: 1 - m_UpDirection: 1 - m_DrawArea: - serializedVersion: 2 - x: 0 - y: 17 - width: 971 - height: 404 - m_Scale: {x: 2, y: 2} - m_Translation: {x: 485.5, y: 202} - m_MarginLeft: 0 - m_MarginRight: 0 - m_MarginTop: 0 - m_MarginBottom: 0 - m_LastShownAreaInsideMargins: - serializedVersion: 2 - x: -242.75 - y: -101 - width: 485.5 - height: 202 - m_MinimalGUI: 1 - m_defaultScale: 2 - m_TargetTexture: {fileID: 0} - m_CurrentColorSpace: 0 - m_LastWindowPixelSize: {x: 1942, y: 842} - m_ClearInEditMode: 1 - m_NoCameraWarning: 1 - m_LowResolutionForAspectRatios: 01000000000100000100 ---- !u!114 &16 -MonoBehaviour: - m_ObjectHideFlags: 52 - m_PrefabParentObject: {fileID: 0} - m_PrefabInternal: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 1 - m_Script: {fileID: 12013, guid: 0000000000000000e000000000000000, type: 0} - m_Name: - m_EditorClassIdentifier: - m_AutoRepaintOnSceneChange: 1 - m_MinSize: {x: 200, y: 200} - m_MaxSize: {x: 4000, y: 4000} - m_TitleContent: - m_Text: Scene - m_Image: {fileID: 2318424515335265636, guid: 0000000000000000d000000000000000, - type: 0} - m_Tooltip: - m_DepthBufferBits: 32 - m_Pos: - serializedVersion: 2 - x: 0 - y: 19 - width: 971 - height: 445 - m_SceneLighting: 1 - lastFramingTime: 0 - m_2DMode: 0 - m_isRotationLocked: 0 - m_AudioPlay: 0 - m_Position: - m_Target: {x: 0, y: 0, z: 0} - speed: 2 - m_Value: {x: 0, y: 0, z: 0} - m_RenderMode: 0 - m_ValidateTrueMetals: 0 - m_SceneViewState: - showFog: 1 - showMaterialUpdate: 0 - showSkybox: 1 - showFlares: 1 - showImageEffects: 1 - grid: - xGrid: - m_Target: 0 - speed: 2 - m_Value: 0 - yGrid: - m_Target: 1 - speed: 2 - m_Value: 1 - zGrid: - m_Target: 0 - speed: 2 - m_Value: 0 - m_Rotation: - m_Target: {x: -0.08717229, y: 0.89959055, z: -0.21045254, w: -0.3726226} - speed: 2 - m_Value: {x: -0.08717229, y: 0.89959055, z: -0.21045254, w: -0.3726226} - m_Size: - m_Target: 10 - speed: 2 - m_Value: 10 - m_Ortho: - m_Target: 0 - speed: 2 - m_Value: 0 - m_LastSceneViewRotation: {x: 0, y: 0, z: 0, w: 0} - m_LastSceneViewOrtho: 0 - m_ReplacementShader: {fileID: 0} - m_ReplacementString: - m_LastLockedObject: {fileID: 0} - m_ViewIsLockedToObject: 0 ---- !u!114 &17 -MonoBehaviour: - m_ObjectHideFlags: 52 - m_PrefabParentObject: {fileID: 0} - m_PrefabInternal: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 1 - m_Script: {fileID: 12061, guid: 0000000000000000e000000000000000, type: 0} - m_Name: - m_EditorClassIdentifier: - m_AutoRepaintOnSceneChange: 0 - m_MinSize: {x: 200, y: 200} - m_MaxSize: {x: 4000, y: 4000} - m_TitleContent: - m_Text: Hierarchy - m_Image: {fileID: -590624980919486359, guid: 0000000000000000d000000000000000, - type: 0} - m_Tooltip: - m_DepthBufferBits: 0 - m_Pos: - serializedVersion: 2 - x: 2 - y: 19 - width: 286 - height: 445 - m_TreeViewState: - scrollPos: {x: 0, y: 0} - m_SelectedIDs: 68fbffff - m_LastClickedID: -1176 - m_ExpandedIDs: 7efbffff00000000 - m_RenameOverlay: - m_UserAcceptedRename: 0 - m_Name: - m_OriginalName: - m_EditFieldRect: - serializedVersion: 2 - x: 0 - y: 0 - width: 0 - height: 0 - m_UserData: 0 - m_IsWaitingForDelay: 0 - m_IsRenaming: 0 - m_OriginalEventType: 11 - m_IsRenamingFilename: 0 - m_ClientGUIView: {fileID: 0} - m_SearchString: - m_ExpandedScenes: - - - m_CurrenRootInstanceID: 0 - m_Locked: 0 - m_CurrentSortingName: TransformSorting diff --git a/Assets/00_Core/TutorialInfo/Layout.wlt.meta b/Assets/00_Core/TutorialInfo/Layout.wlt.meta deleted file mode 100644 index c0c8c77..0000000 --- a/Assets/00_Core/TutorialInfo/Layout.wlt.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: eabc9546105bf4accac1fd62a63e88e6 -timeCreated: 1487337779 -licenseType: Store -DefaultImporter: - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/00_Core/TutorialInfo/Scripts.meta b/Assets/00_Core/TutorialInfo/Scripts.meta deleted file mode 100644 index 02da605..0000000 --- a/Assets/00_Core/TutorialInfo/Scripts.meta +++ /dev/null @@ -1,9 +0,0 @@ -fileFormatVersion: 2 -guid: 5a9bcd70e6a4b4b05badaa72e827d8e0 -folderAsset: yes -timeCreated: 1475835190 -licenseType: Store -DefaultImporter: - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/00_Core/TutorialInfo/Scripts/Editor.meta b/Assets/00_Core/TutorialInfo/Scripts/Editor.meta deleted file mode 100644 index f59f099..0000000 --- a/Assets/00_Core/TutorialInfo/Scripts/Editor.meta +++ /dev/null @@ -1,9 +0,0 @@ -fileFormatVersion: 2 -guid: 3ad9b87dffba344c89909c6d1b1c17e1 -folderAsset: yes -timeCreated: 1475593892 -licenseType: Store -DefaultImporter: - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/00_Core/TutorialInfo/Scripts/Editor/ReadmeEditor.cs b/Assets/00_Core/TutorialInfo/Scripts/Editor/ReadmeEditor.cs deleted file mode 100644 index ad55eca..0000000 --- a/Assets/00_Core/TutorialInfo/Scripts/Editor/ReadmeEditor.cs +++ /dev/null @@ -1,242 +0,0 @@ -using System.Collections; -using System.Collections.Generic; -using UnityEngine; -using UnityEditor; -using System; -using System.IO; -using System.Reflection; - -[CustomEditor(typeof(Readme))] -[InitializeOnLoad] -public class ReadmeEditor : Editor -{ - static string s_ShowedReadmeSessionStateName = "ReadmeEditor.showedReadme"; - - static string s_ReadmeSourceDirectory = "Assets/TutorialInfo"; - - const float k_Space = 16f; - - static ReadmeEditor() - { - EditorApplication.delayCall += SelectReadmeAutomatically; - } - - static void RemoveTutorial() - { - if (EditorUtility.DisplayDialog("Remove Readme Assets", - - $"All contents under {s_ReadmeSourceDirectory} will be removed, are you sure you want to proceed?", - "Proceed", - "Cancel")) - { - if (Directory.Exists(s_ReadmeSourceDirectory)) - { - FileUtil.DeleteFileOrDirectory(s_ReadmeSourceDirectory); - FileUtil.DeleteFileOrDirectory(s_ReadmeSourceDirectory + ".meta"); - } - else - { - Debug.Log($"Could not find the Readme folder at {s_ReadmeSourceDirectory}"); - } - - var readmeAsset = SelectReadme(); - if (readmeAsset != null) - { - var path = AssetDatabase.GetAssetPath(readmeAsset); - FileUtil.DeleteFileOrDirectory(path + ".meta"); - FileUtil.DeleteFileOrDirectory(path); - } - - AssetDatabase.Refresh(); - } - } - - static void SelectReadmeAutomatically() - { - if (!SessionState.GetBool(s_ShowedReadmeSessionStateName, false)) - { - var readme = SelectReadme(); - SessionState.SetBool(s_ShowedReadmeSessionStateName, true); - - if (readme && !readme.loadedLayout) - { - LoadLayout(); - readme.loadedLayout = true; - } - } - } - - static void LoadLayout() - { - var assembly = typeof(EditorApplication).Assembly; - var windowLayoutType = assembly.GetType("UnityEditor.WindowLayout", true); - var method = windowLayoutType.GetMethod("LoadWindowLayout", BindingFlags.Public | BindingFlags.Static); - method.Invoke(null, new object[] { Path.Combine(Application.dataPath, "TutorialInfo/Layout.wlt"), false }); - } - - static Readme SelectReadme() - { - var ids = AssetDatabase.FindAssets("Readme t:Readme"); - if (ids.Length == 1) - { - var readmeObject = AssetDatabase.LoadMainAssetAtPath(AssetDatabase.GUIDToAssetPath(ids[0])); - - Selection.objects = new UnityEngine.Object[] { readmeObject }; - - return (Readme)readmeObject; - } - else - { - Debug.Log("Couldn't find a readme"); - return null; - } - } - - protected override void OnHeaderGUI() - { - var readme = (Readme)target; - Init(); - - var iconWidth = Mathf.Min(EditorGUIUtility.currentViewWidth / 3f - 20f, 128f); - - GUILayout.BeginHorizontal("In BigTitle"); - { - if (readme.icon != null) - { - GUILayout.Space(k_Space); - GUILayout.Label(readme.icon, GUILayout.Width(iconWidth), GUILayout.Height(iconWidth)); - } - GUILayout.Space(k_Space); - GUILayout.BeginVertical(); - { - - GUILayout.FlexibleSpace(); - GUILayout.Label(readme.title, TitleStyle); - GUILayout.FlexibleSpace(); - } - GUILayout.EndVertical(); - GUILayout.FlexibleSpace(); - } - GUILayout.EndHorizontal(); - } - - public override void OnInspectorGUI() - { - var readme = (Readme)target; - Init(); - - foreach (var section in readme.sections) - { - if (!string.IsNullOrEmpty(section.heading)) - { - GUILayout.Label(section.heading, HeadingStyle); - } - - if (!string.IsNullOrEmpty(section.text)) - { - GUILayout.Label(section.text, BodyStyle); - } - - if (!string.IsNullOrEmpty(section.linkText)) - { - if (LinkLabel(new GUIContent(section.linkText))) - { - Application.OpenURL(section.url); - } - } - - GUILayout.Space(k_Space); - } - - if (GUILayout.Button("Remove Readme Assets", ButtonStyle)) - { - RemoveTutorial(); - } - } - - bool m_Initialized; - - GUIStyle LinkStyle - { - get { return m_LinkStyle; } - } - - [SerializeField] - GUIStyle m_LinkStyle; - - GUIStyle TitleStyle - { - get { return m_TitleStyle; } - } - - [SerializeField] - GUIStyle m_TitleStyle; - - GUIStyle HeadingStyle - { - get { return m_HeadingStyle; } - } - - [SerializeField] - GUIStyle m_HeadingStyle; - - GUIStyle BodyStyle - { - get { return m_BodyStyle; } - } - - [SerializeField] - GUIStyle m_BodyStyle; - - GUIStyle ButtonStyle - { - get { return m_ButtonStyle; } - } - - [SerializeField] - GUIStyle m_ButtonStyle; - - void Init() - { - if (m_Initialized) - return; - m_BodyStyle = new GUIStyle(EditorStyles.label); - m_BodyStyle.wordWrap = true; - m_BodyStyle.fontSize = 14; - m_BodyStyle.richText = true; - - m_TitleStyle = new GUIStyle(m_BodyStyle); - m_TitleStyle.fontSize = 26; - - m_HeadingStyle = new GUIStyle(m_BodyStyle); - m_HeadingStyle.fontStyle = FontStyle.Bold; - m_HeadingStyle.fontSize = 18; - - m_LinkStyle = new GUIStyle(m_BodyStyle); - m_LinkStyle.wordWrap = false; - - // Match selection color which works nicely for both light and dark skins - m_LinkStyle.normal.textColor = new Color(0x00 / 255f, 0x78 / 255f, 0xDA / 255f, 1f); - m_LinkStyle.stretchWidth = false; - - m_ButtonStyle = new GUIStyle(EditorStyles.miniButton); - m_ButtonStyle.fontStyle = FontStyle.Bold; - - m_Initialized = true; - } - - bool LinkLabel(GUIContent label, params GUILayoutOption[] options) - { - var position = GUILayoutUtility.GetRect(label, LinkStyle, options); - - Handles.BeginGUI(); - Handles.color = LinkStyle.normal.textColor; - Handles.DrawLine(new Vector3(position.xMin, position.yMax), new Vector3(position.xMax, position.yMax)); - Handles.color = Color.white; - Handles.EndGUI(); - - EditorGUIUtility.AddCursorRect(position, MouseCursor.Link); - - return GUI.Button(position, label, LinkStyle); - } -} diff --git a/Assets/00_Core/TutorialInfo/Scripts/Readme.cs b/Assets/00_Core/TutorialInfo/Scripts/Readme.cs deleted file mode 100644 index 95f6269..0000000 --- a/Assets/00_Core/TutorialInfo/Scripts/Readme.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using UnityEngine; - -public class Readme : ScriptableObject -{ - public Texture2D icon; - public string title; - public Section[] sections; - public bool loadedLayout; - - [Serializable] - public class Section - { - public string heading, text, linkText, url; - } -} diff --git a/Assets/00_Core/TutorialInfo/Scripts/Readme.cs.meta b/Assets/00_Core/TutorialInfo/Scripts/Readme.cs.meta deleted file mode 100644 index 935153f..0000000 --- a/Assets/00_Core/TutorialInfo/Scripts/Readme.cs.meta +++ /dev/null @@ -1,12 +0,0 @@ -fileFormatVersion: 2 -guid: fcf7219bab7fe46a1ad266029b2fee19 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: - - icon: {instanceID: 0} - executionOrder: 0 - icon: {fileID: 2800000, guid: a186f8a87ca4f4d3aa864638ad5dfb65, type: 3} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Board.meta b/Assets/10_Scripts/10_Runtime/10_Core/10_Board.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Board.meta rename to Assets/10_Scripts/10_Runtime/10_Core/10_Board.meta diff --git a/Assets/10_Scripts/10_Runtime/10_Core/10_Board/BoardBounds.cs b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/BoardBounds.cs new file mode 100644 index 0000000..de980d1 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/BoardBounds.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; + +namespace CircuitCraft.Core +{ + /// + /// Represents an axis-aligned rectangle in board grid space. + /// + public readonly struct BoardBounds : IEquatable + { + /// Gets an unbounded sentinel value. + public static BoardBounds Unbounded { get; } = new(int.MinValue / 2, int.MinValue / 2, int.MaxValue, int.MaxValue); + + /// Gets the minimum X coordinate (inclusive). + public int MinX { get; } + + /// Gets the minimum Y coordinate (inclusive). + public int MinY { get; } + + /// Gets the maximum X coordinate (exclusive). + public int MaxX { get; } + + /// Gets the maximum Y coordinate (exclusive). + public int MaxY { get; } + + /// Gets the width of the board bounds in grid cells. + public int Width { get; } + + /// Gets the height of the board bounds in grid cells. + public int Height { get; } + + /// + /// Creates new board bounds at origin. + /// + /// Width in grid cells. + /// Height in grid cells. + public BoardBounds(int width, int height) + : this(0, 0, width, height) + { + } + + /// + /// Creates new board bounds from a minimum coordinate and size. + /// + /// Minimum X coordinate (inclusive). + /// Minimum Y coordinate (inclusive). + /// Width in grid cells. + /// Height in grid cells. + public BoardBounds(int minX, int minY, int width, int height) + { + MinX = minX; + MinY = minY; + Width = Math.Max(0, width); + Height = Math.Max(0, height); + MaxX = MinX + Width; + MaxY = MinY + Height; + } + + /// + /// Creates board bounds that enclose the provided positions. + /// + /// Positions to include. + /// Computed bounds, or a 1x1 bounds at origin if empty. + public static BoardBounds FromContent(IEnumerable positions) + { + if (positions is null) + throw new ArgumentNullException(nameof(positions)); + + using (var enumerator = positions.GetEnumerator()) + { + if (!enumerator.MoveNext()) + { + return new BoardBounds(0, 0, 1, 1); + } + + var first = enumerator.Current; + var minX = first.X; + var minY = first.Y; + var maxX = first.X; + var maxY = first.Y; + + while (enumerator.MoveNext()) + { + var current = enumerator.Current; + minX = Math.Min(minX, current.X); + minY = Math.Min(minY, current.Y); + maxX = Math.Max(maxX, current.X); + maxY = Math.Max(maxY, current.Y); + } + + return new BoardBounds(minX, minY, (maxX - minX) + 1, (maxY - minY) + 1); + } + } + + /// + /// Checks if a grid position is within the bounds. + /// + /// Position to check. + /// True if position is within bounds. + public bool Contains(GridPosition pos) + { + return pos.X >= MinX && pos.X < MaxX && + pos.Y >= MinY && pos.Y < MaxY; + } + + /// + /// Checks equality with another board bounds. + /// + public bool Equals(BoardBounds other) + { + return MinX == other.MinX && MinY == other.MinY && + Width == other.Width && Height == other.Height; + } + + /// + /// Checks equality with an object. + /// + public override bool Equals(object obj) + { + return obj is BoardBounds other && Equals(other); + } + + /// + /// Gets the hash code. + /// + public override int GetHashCode() + { + unchecked + { + var hash = MinX; + hash = (hash * 397) ^ MinY; + hash = (hash * 397) ^ Width; + hash = (hash * 397) ^ Height; + return hash; + } + } + + /// + /// Equality operator. + /// + public static bool operator ==(BoardBounds left, BoardBounds right) + { + return left.Equals(right); + } + + /// + /// Inequality operator. + /// + public static bool operator !=(BoardBounds left, BoardBounds right) + { + return !left.Equals(right); + } + + /// + /// Returns a string representation. + /// + public override string ToString() + { + return $"({MinX},{MinY}) {Width}x{Height}"; + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Board/BoardBounds.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/BoardBounds.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Board/BoardBounds.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/10_Board/BoardBounds.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/10_Core/10_Board/BoardState.cs b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/BoardState.cs new file mode 100644 index 0000000..7446de2 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/BoardState.cs @@ -0,0 +1,459 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CircuitCraft.Core +{ + /// + /// Represents the complete state of the circuit board. + /// This is the central domain model containing all placed components and nets. + /// + public class BoardState + { + private readonly List _components = new(); + private readonly Dictionary _componentsByPosition = new(); + private readonly List _nets = new(); + private readonly List _traces = new(); + private readonly Dictionary _componentsById = new(); + private readonly Dictionary _netsById = new(); + private readonly Dictionary _tracesById = new(); + private readonly Dictionary> _tracesByNetId = new(); + private readonly IReadOnlyList _readOnlyComponents; + private readonly IReadOnlyList _readOnlyNets; + private readonly IReadOnlyList _readOnlyTraces; + private int _nextComponentId = 1; + private int _nextNetId = 1; + private int _nextTraceId = 1; + + /// Gets the suggested board area for initial framing and UX. + public BoardBounds SuggestedBounds { get; } + + /// Gets the suggested board area for backward compatibility. + public BoardBounds Bounds => SuggestedBounds; + + /// Gets the read-only list of placed components. + public IReadOnlyList Components => _readOnlyComponents; + + /// Gets the read-only list of nets. + public IReadOnlyList Nets => _readOnlyNets; + + /// Gets the read-only list of traces. + public IReadOnlyList Traces => _readOnlyTraces; + + /// Event raised when a component is placed on the board. + public event Action OnComponentPlaced; + + /// Event raised when a component is removed from the board. + public event Action OnComponentRemoved; + + /// Event raised when a net is created. + public event Action OnNetCreated; + + /// Event raised when two pins are connected via a net. + public event Action OnPinsConnected; + + /// Event raised when a trace is added to the board. + public event Action OnTraceAdded; + + /// Event raised when a trace is removed from the board. + public event Action OnTraceRemoved; + + /// + /// Creates a new board state. + /// + /// Board width in grid cells. + /// Board height in grid cells. + public BoardState(int width, int height) + { + SuggestedBounds = new BoardBounds(width, height); + _readOnlyComponents = _components.AsReadOnly(); + _readOnlyNets = _nets.AsReadOnly(); + _readOnlyTraces = _traces.AsReadOnly(); + } + + /// + /// Places a component on the board. + /// + /// Component definition ID. + /// Position on the board. + /// Rotation (0, 90, 180, 270). + /// Pin instances for this component. + /// User-specified custom electrical value (null to use definition default). + /// True when this component is pre-placed and non-removable. + /// The placed component. + public PlacedComponent PlaceComponent(string componentDefId, GridPosition position, + int rotation, IEnumerable pins, float? customValue = null, bool isFixed = false) + { + if (_componentsByPosition.ContainsKey(position)) + throw new InvalidOperationException($"Position {position} is already occupied."); + + var instanceId = _nextComponentId++; + var component = new PlacedComponent(instanceId, componentDefId, position, rotation, pins, customValue, isFixed); + _components.Add(component); + _componentsById.Add(component.InstanceId, component); + _componentsByPosition.Add(position, component); + + OnComponentPlaced?.Invoke(component); + return component; + } + + /// + /// Removes a component from the board. + /// + /// Component instance ID. + /// True if component was removed. + public bool RemoveComponent(int instanceId) + { + if (!_componentsById.TryGetValue(instanceId, out var component)) + return false; + + if (component.IsFixed) return false; + + var removedPinPositions = new HashSet(); + foreach (var pin in component.Pins) + { + removedPinPositions.Add(component.GetPinWorldPosition(pin.PinIndex)); + } + + // Remove component's pins from their connected nets. + var netsToCheck = new HashSet(); + foreach (var pin in component.Pins) + { + if (!pin.ConnectedNetId.HasValue) + { + continue; + } + + int netId = pin.ConnectedNetId.Value; + netsToCheck.Add(netId); + + var net = GetNet(netId); + if (net is null) + { + continue; + } + + var pinReference = new PinReference(instanceId, pin.PinIndex, component.GetPinWorldPosition(pin.PinIndex)); + net.RemovePin(pinReference); + } + + // Remove empty nets. + foreach (var netId in netsToCheck) + { + var net = GetNet(netId); + if (net is not null && net.ConnectedPins.Count == 0) + { + _nets.Remove(net); + _netsById.Remove(netId); + } + } + + // Remove any traces that start or end on removed component pins. + for (int i = _traces.Count - 1; i >= 0; i--) + { + var trace = _traces[i]; + if (removedPinPositions.Contains(trace.Start) || removedPinPositions.Contains(trace.End)) + { + RemoveTrace(trace.SegmentId); + } + } + + _components.Remove(component); + _componentsById.Remove(instanceId); + _componentsByPosition.Remove(component.Position); + OnComponentRemoved?.Invoke(instanceId); + return true; + } + + /// + /// Adds a trace segment to an existing net. + /// + /// Target net ID. + /// Trace start position. + /// Trace end position. + /// The created trace segment. + public TraceSegment AddTrace(int netId, GridPosition start, GridPosition end) + { + var net = GetNet(netId); + if (net is null) + throw new ArgumentException($"Net {netId} not found.", nameof(netId)); + + var trace = new TraceSegment(_nextTraceId++, netId, start, end); + _traces.Add(trace); + _tracesById.Add(trace.SegmentId, trace); + if (!_tracesByNetId.TryGetValue(netId, out var netTraces)) + { + netTraces = new(); + _tracesByNetId[netId] = netTraces; + } + + netTraces.Add(trace); + OnTraceAdded?.Invoke(trace); + return trace; + } + + /// + /// Computes the axis-aligned bounding rectangle that contains all placed components and trace endpoints. + /// Returns SuggestedBounds when the board has no content. + /// + /// Computed content bounds or SuggestedBounds when empty. + public BoardBounds ComputeContentBounds() + { + var positions = new List(); + + foreach (var component in _components) + { + positions.Add(component.Position); + foreach (var pin in component.Pins) + { + positions.Add(component.GetPinWorldPosition(pin.PinIndex)); + } + } + + foreach (var trace in _traces) + { + positions.Add(trace.Start); + positions.Add(trace.End); + } + + if (positions.Count == 0) + return SuggestedBounds; + + return BoardBounds.FromContent(positions); + } + + /// + /// Removes a trace segment by ID. + /// + /// Trace segment ID. + /// True when removed. + public bool RemoveTrace(int segmentId) + { + if (!_tracesById.TryGetValue(segmentId, out var trace)) + return false; + + _traces.Remove(trace); + _tracesById.Remove(segmentId); + List netTraceList = null; + if (_tracesByNetId.TryGetValue(trace.NetId, out netTraceList)) + { + netTraceList.Remove(trace); + } + + OnTraceRemoved?.Invoke(segmentId); + + // If the net has no remaining traces, clear pin links and remove it. + if (netTraceList is null || netTraceList.Count == 0) + { + var net = GetNet(trace.NetId); + if (net is not null) + { + foreach (var pin in net.ConnectedPins.ToList()) + { + var component = GetComponent(pin.ComponentInstanceId); + PinInstance pinInstance = null; + if (component?.Pins is not null) + { + foreach (var existingPin in component.Pins) + { + if (existingPin.PinIndex == pin.PinIndex) + { + pinInstance = existingPin; + break; + } + } + } + if (pinInstance is not null && pinInstance.ConnectedNetId == net.NetId) + { + pinInstance.ConnectedNetId = null; + } + + net.RemovePin(pin); + } + + _nets.Remove(net); + _netsById.Remove(net.NetId); + } + + _tracesByNetId.Remove(trace.NetId); + } + + return true; + } + + /// + /// Gets all trace segments that belong to the specified net. + /// + /// Net ID. + /// Matching trace segments. + public IReadOnlyList GetTraces(int netId) + { + if (_tracesByNetId.TryGetValue(netId, out var netTraces)) + return netTraces; + + return Array.Empty(); + } + + /// + /// Creates a new net. + /// + /// Name for the net (e.g., "VIN", "GND"). + /// The created net. + public Net CreateNet(string netName) + { + var netId = _nextNetId++; + var net = new Net(netId, netName); + _nets.Add(net); + _netsById.Add(net.NetId, net); + + OnNetCreated?.Invoke(net); + return net; + } + + /// + /// Creates a net with a specific ID, used for undo operations that need to restore + /// the original net identity. Updates _nextNetId if needed to avoid future collisions. + /// + /// The specific net ID to restore. + /// Name for the net. + /// The created net. + /// If a net with this ID already exists. + public Net CreateNetWithId(int netId, string netName) + { + if (_netsById.ContainsKey(netId)) + throw new InvalidOperationException($"Net {netId} already exists."); + + var net = new Net(netId, netName); + _nets.Add(net); + _netsById.Add(netId, net); + + // Ensure future auto-generated IDs don't collide + if (netId >= _nextNetId) + _nextNetId = netId + 1; + + OnNetCreated?.Invoke(net); + return net; + } + + /// + /// Connects a pin to a net. + /// + /// Net ID. + /// Pin reference to connect. + public void ConnectPinToNet(int netId, PinReference pin) + { + var net = GetNet(netId); + if (net is null) + throw new ArgumentException($"Net {netId} not found.", nameof(netId)); + + // Update component's pin instance + var component = GetComponent(pin.ComponentInstanceId); + if (component is null) + throw new ArgumentException($"Component {pin.ComponentInstanceId} not found."); + + PinInstance pinInstance = null; + foreach (var existingPin in component.Pins) + { + if (existingPin.PinIndex == pin.PinIndex) + { + pinInstance = existingPin; + break; + } + } + if (pinInstance is null) + throw new ArgumentException($"Pin {pin.PinIndex} not found on component {pin.ComponentInstanceId}."); + + if (pinInstance.ConnectedNetId.HasValue && pinInstance.ConnectedNetId.Value != netId) + { + var previousNet = GetNet(pinInstance.ConnectedNetId.Value); + if (previousNet is not null) + { + previousNet.RemovePin(pin); + if (previousNet.ConnectedPins.Count == 0) + { + _nets.Remove(previousNet); + _netsById.Remove(previousNet.NetId); + } + } + } + + pinInstance.ConnectedNetId = netId; + net.AddPin(pin); + + // Check if this creates a connection between two pins + var connectedPins = net.ConnectedPins; + if (connectedPins.Count >= 2) + { + var otherPin = connectedPins[connectedPins.Count - 2]; + OnPinsConnected?.Invoke(netId, otherPin, pin); + } + } + + /// + /// Gets a component by instance ID. + /// + /// Component instance ID. + /// The component, or null if not found. + public PlacedComponent GetComponent(int instanceId) + { + _componentsById.TryGetValue(instanceId, out var component); + return component; + } + + /// + /// Gets a net by ID. + /// + /// Net ID. + /// The net, or null if not found. + public Net GetNet(int netId) + { + _netsById.TryGetValue(netId, out var net); + return net; + } + + /// + /// Gets a net by name. + /// + /// Net name (e.g., "VIN", "GND"). + /// The net, or null if not found. + public Net GetNetByName(string netName) + { + foreach (var net in _nets) + { + if (net.NetName == netName) + return net; + } + + return null; + } + + /// + /// Checks if a position is occupied by a component. + /// + /// Position to check. + /// True if position is occupied. + public bool IsPositionOccupied(GridPosition pos) + { + return _componentsByPosition.ContainsKey(pos); + } + + /// + /// Gets a component at a specific board position. + /// + /// Position to query. + /// The component at the position, or null if unoccupied. + public PlacedComponent GetComponentAt(GridPosition position) + { + _componentsByPosition.TryGetValue(position, out var component); + return component; + } + + /// + /// Returns a string representation of the board state. + /// + public override string ToString() + { + return $"Board[{SuggestedBounds}] {_components.Count} components, {_nets.Count} nets"; + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Board/BoardState.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/BoardState.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Board/BoardState.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/10_Board/BoardState.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Board/Net.cs b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/Net.cs similarity index 99% rename from Assets/10_Scripts/10_Runtime/10_Core/Board/Net.cs rename to Assets/10_Scripts/10_Runtime/10_Core/10_Board/Net.cs index b3074db..cb4a593 100644 --- a/Assets/10_Scripts/10_Runtime/10_Core/Board/Net.cs +++ b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/Net.cs @@ -9,7 +9,7 @@ namespace CircuitCraft.Core /// public class Net { - private readonly List _connectedPins = new List(); + private readonly List _connectedPins = new(); /// Gets the unique identifier for this net. public int NetId { get; } diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Board/Net.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/Net.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Board/Net.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/10_Board/Net.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Board/PinInstance.cs b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/PinInstance.cs similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Board/PinInstance.cs rename to Assets/10_Scripts/10_Runtime/10_Core/10_Board/PinInstance.cs diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Board/PinInstance.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/PinInstance.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Board/PinInstance.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/10_Board/PinInstance.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Board/PinReference.cs b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/PinReference.cs similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Board/PinReference.cs rename to Assets/10_Scripts/10_Runtime/10_Core/10_Board/PinReference.cs diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Board/PinReference.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/PinReference.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Board/PinReference.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/10_Board/PinReference.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Board/PlacedComponent.cs b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/PlacedComponent.cs similarity index 57% rename from Assets/10_Scripts/10_Runtime/10_Core/Board/PlacedComponent.cs rename to Assets/10_Scripts/10_Runtime/10_Core/10_Board/PlacedComponent.cs index f029da0..30901b2 100644 --- a/Assets/10_Scripts/10_Runtime/10_Core/Board/PlacedComponent.cs +++ b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/PlacedComponent.cs @@ -1,15 +1,34 @@ using System; using System.Collections.Generic; -using System.Linq; namespace CircuitCraft.Core { + /// + /// Defines canonical rotation constants used for placed components. + /// + public static class RotationConstants + { + /// No rotation in degrees. + public const int None = 0; + /// Quarter-turn clockwise rotation in degrees. + public const int Quarter = 90; + /// Half-turn rotation in degrees. + public const int Half = 180; + /// Three-quarter-turn clockwise rotation in degrees. + public const int ThreeQuarter = 270; + /// Full turn in degrees. + public const int Full = 360; + /// Set of valid component rotation values. + public static readonly int[] ValidRotations = { None, Quarter, Half, ThreeQuarter }; + } + /// /// Represents a component instance placed on the circuit board. /// public class PlacedComponent { - private readonly List _pins = new List(); + private readonly List _pins = new(); + private readonly IReadOnlyList _readOnlyPins; /// Gets the unique instance ID for this component. public int InstanceId { get; } @@ -23,8 +42,14 @@ public class PlacedComponent /// Gets the rotation of this component in degrees (0, 90, 180, 270). public int Rotation { get; } + /// Gets the user-specified custom electrical value (null if using definition default). + public float? CustomValue { get; } + + /// Gets whether this component is fixed and cannot be moved/removed by the player. + public bool IsFixed { get; } + /// Gets the read-only list of pins on this component. - public IReadOnlyList Pins => _pins.AsReadOnly(); + public IReadOnlyList Pins => _readOnlyPins; /// /// Creates a new placed component. @@ -34,23 +59,28 @@ public class PlacedComponent /// Position on the board grid. /// Rotation in degrees (0, 90, 180, 270). /// Pin instances for this component. + /// User-specified custom electrical value (null to use definition default). + /// True when this component is pre-placed and non-removable. public PlacedComponent(int instanceId, string componentDefId, GridPosition position, - int rotation, IEnumerable pins) + int rotation, IEnumerable pins, float? customValue = null, bool isFixed = false) { if (instanceId < 0) throw new ArgumentOutOfRangeException(nameof(instanceId), "Instance ID must be non-negative."); if (string.IsNullOrWhiteSpace(componentDefId)) throw new ArgumentException("Component definition ID cannot be null or empty.", nameof(componentDefId)); - if (rotation != 0 && rotation != 90 && rotation != 180 && rotation != 270) + if (Array.IndexOf(RotationConstants.ValidRotations, rotation) < 0) throw new ArgumentOutOfRangeException(nameof(rotation), "Rotation must be 0, 90, 180, or 270 degrees."); - if (pins == null) + if (pins is null) throw new ArgumentNullException(nameof(pins)); InstanceId = instanceId; ComponentDefinitionId = componentDefId; Position = position; Rotation = rotation; + CustomValue = customValue; + IsFixed = isFixed; _pins.AddRange(pins); + _readOnlyPins = _pins.AsReadOnly(); } /// @@ -60,10 +90,11 @@ public PlacedComponent(int instanceId, string componentDefId, GridPosition posit /// World grid position of the pin. public GridPosition GetPinWorldPosition(int pinIndex) { - var pin = _pins.FirstOrDefault(p => p.PinIndex == pinIndex); - if (pin == null) + if (pinIndex < 0 || pinIndex >= _pins.Count) throw new ArgumentException($"Pin index {pinIndex} not found on component {InstanceId}.", nameof(pinIndex)); + var pin = _pins[pinIndex]; + // Apply rotation transformation to local position var localPos = pin.LocalPosition; var rotatedPos = RotatePosition(localPos, Rotation); @@ -83,22 +114,17 @@ public GridPosition GetPinWorldPosition(int pinIndex) /// Rotated position. private GridPosition RotatePosition(GridPosition localPos, int degrees) { - switch (degrees) + return degrees switch { - case 0: - return localPos; - case 90: - // Rotate 90° clockwise: (x, y) -> (y, -x) - return new GridPosition(localPos.Y, -localPos.X); - case 180: - // Rotate 180°: (x, y) -> (-x, -y) - return new GridPosition(-localPos.X, -localPos.Y); - case 270: - // Rotate 270° clockwise (90° counter-clockwise): (x, y) -> (-y, x) - return new GridPosition(-localPos.Y, localPos.X); - default: - throw new ArgumentException($"Invalid rotation: {degrees}"); - } + RotationConstants.None => localPos, + // Rotate 90° clockwise: (x, y) -> (y, -x) + RotationConstants.Quarter => new GridPosition(localPos.Y, -localPos.X), + // Rotate 180°: (x, y) -> (-x, -y) + RotationConstants.Half => new GridPosition(-localPos.X, -localPos.Y), + // Rotate 270° clockwise (90° counter-clockwise): (x, y) -> (-y, x) + RotationConstants.ThreeQuarter => new GridPosition(-localPos.Y, localPos.X), + _ => throw new ArgumentException($"Invalid rotation: {degrees}") + }; } /// @@ -106,7 +132,8 @@ private GridPosition RotatePosition(GridPosition localPos, int degrees) /// public override string ToString() { - return $"Component[{InstanceId}:{ComponentDefinitionId}@{Position} R{Rotation}°] ({_pins.Count} pins)"; + var customValueStr = CustomValue.HasValue ? $" V={CustomValue.Value}" : ""; + return $"Component[{InstanceId}:{ComponentDefinitionId}@{Position} R{Rotation}°]{customValueStr} ({_pins.Count} pins)"; } } } diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Board/PlacedComponent.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/PlacedComponent.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Board/PlacedComponent.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/10_Board/PlacedComponent.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/10_Core/10_Board/TraceSegment.cs b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/TraceSegment.cs new file mode 100644 index 0000000..142682b --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/TraceSegment.cs @@ -0,0 +1,50 @@ +using System; + +namespace CircuitCraft.Core +{ + /// + /// Represents a routed board trace segment between two orthogonal grid points. + /// + public class TraceSegment + { + /// Gets the unique segment identifier. + public int SegmentId { get; } + + /// Gets the net this trace segment belongs to. + public int NetId { get; } + + /// Gets the segment start position. + public GridPosition Start { get; } + + /// Gets the segment end position. + public GridPosition End { get; } + + /// + /// Creates a new orthogonal trace segment. + /// + public TraceSegment(int segmentId, int netId, GridPosition start, GridPosition end) + { + if (segmentId <= 0) + throw new ArgumentOutOfRangeException(nameof(segmentId), "Segment ID must be positive."); + if (netId <= 0) + throw new ArgumentOutOfRangeException(nameof(netId), "Net ID must be positive."); + if (start == end) + throw new ArgumentException("Trace segment start and end cannot be the same point."); + if (start.X != end.X && start.Y != end.Y) + throw new ArgumentException("Trace segment must be Manhattan (horizontal or vertical).", nameof(end)); + + SegmentId = segmentId; + NetId = netId; + Start = start; + End = end; + } + + /// + /// Returns a string representation of this trace segment. + /// + public override string ToString() + { + return $"Trace[{SegmentId}] Net{NetId}: {Start} -> {End}"; + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/60_Utils/SpiceSharpTestRunner.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/TraceSegment.cs.meta similarity index 83% rename from Assets/10_Scripts/10_Runtime/60_Utils/SpiceSharpTestRunner.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/10_Board/TraceSegment.cs.meta index a180602..b9dd6cc 100644 --- a/Assets/10_Scripts/10_Runtime/60_Utils/SpiceSharpTestRunner.cs.meta +++ b/Assets/10_Scripts/10_Runtime/10_Core/10_Board/TraceSegment.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 40903f774fe956f4cadd7e4a8386c2d8 +guid: 6ec2ea0ac29005848809e37ab5859115 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Grid.meta b/Assets/10_Scripts/10_Runtime/10_Core/20_Grid.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Grid.meta rename to Assets/10_Scripts/10_Runtime/10_Core/20_Grid.meta diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Grid/GridPosition.cs b/Assets/10_Scripts/10_Runtime/10_Core/20_Grid/GridPosition.cs similarity index 81% rename from Assets/10_Scripts/10_Runtime/10_Core/Grid/GridPosition.cs rename to Assets/10_Scripts/10_Runtime/10_Core/20_Grid/GridPosition.cs index 59c9630..869a3ff 100644 --- a/Assets/10_Scripts/10_Runtime/10_Core/Grid/GridPosition.cs +++ b/Assets/10_Scripts/10_Runtime/10_Core/20_Grid/GridPosition.cs @@ -50,6 +50,20 @@ public GridPosition[] GetNeighbors() }; } + /// + /// Fills a buffer with the 4-connected neighbors (up, down, left, right). + /// The buffer must have at least offset + 4 elements. + /// + /// Destination buffer. + /// Starting index in the buffer (default 0). + public void FillNeighbors(GridPosition[] buffer, int offset = 0) + { + buffer[offset] = new GridPosition(X, Y + 1); // Up + buffer[offset + 1] = new GridPosition(X, Y - 1); // Down + buffer[offset + 2] = new GridPosition(X - 1, Y); // Left + buffer[offset + 3] = new GridPosition(X + 1, Y); // Right + } + /// /// Checks equality with another grid position. /// diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Grid/GridPosition.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/20_Grid/GridPosition.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Grid/GridPosition.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/20_Grid/GridPosition.cs.meta diff --git a/Assets/10_Scripts/30_Data.meta b/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring.meta similarity index 77% rename from Assets/10_Scripts/30_Data.meta rename to Assets/10_Scripts/10_Runtime/10_Core/30_Scoring.meta index 9367fdd..9103370 100644 --- a/Assets/10_Scripts/30_Data.meta +++ b/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: a80f2c05f5d481d43913ec285ae97af2 +guid: 7bfd09495d79a4645943f8397a7ca8b5 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/EvaluationResult.cs b/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/EvaluationResult.cs new file mode 100644 index 0000000..af775cf --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/EvaluationResult.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace CircuitCraft.Core +{ + /// + /// Pure C# DTO that mirrors the data needed from StageTestCase + /// so the evaluator stays Unity-free. The Unity caller converts + /// StageTestCase[] to TestCaseInput[]. + /// + public readonly struct TestCaseInput + { + /// Node name used in SimulationResult.GetVoltage(). + public string TestName { get; } + + /// Expected voltage value. + public double ExpectedVoltage { get; } + + /// Allowable error margin. + public double Tolerance { get; } + + /// + /// Creates a simulation test case input record. + /// + /// Node or probe identifier to validate. + /// Expected voltage value for the target. + /// Allowed absolute difference from the expected value. + public TestCaseInput(string testName, double expectedVoltage, double tolerance) + { + TestName = testName ?? throw new ArgumentNullException(nameof(testName)); + ExpectedVoltage = expectedVoltage; + Tolerance = tolerance; + } + } + + /// + /// Result of evaluating a single test case against simulation output. + /// + public class TestCaseResult + { + /// Name of the test case / node. + public string TestName { get; } + + /// Expected voltage value from the test case. + public double ExpectedValue { get; } + + /// Actual voltage value from the simulation. + public double ActualValue { get; } + + /// Allowable error margin. + public double Tolerance { get; } + + /// Whether this individual test case passed. + public bool Passed { get; } + + /// Human-readable message describing the result. + public string Message { get; } + + /// + /// Creates a per-test-case evaluation result. + /// + /// Name of the evaluated test case. + /// Expected target value. + /// Measured value from simulation output. + /// Allowed absolute difference. + /// Whether the test case passed. + /// Human-readable explanation of the outcome. + public TestCaseResult(string testName, double expectedValue, double actualValue, double tolerance, bool passed, string message) + { + TestName = testName; + ExpectedValue = expectedValue; + ActualValue = actualValue; + Tolerance = tolerance; + Passed = passed; + Message = message; + } + } + + /// + /// Aggregated result of evaluating all test cases against a simulation result. + /// + public class EvaluationResult + { + /// True only if ALL test cases passed. + public bool Passed { get; } + + /// Individual results per test case. + public List Results { get; } + + /// Human-readable summary of the evaluation. + public string Summary { get; } + + /// + /// Creates an aggregated evaluation result. + /// + /// True when every test case passed. + /// Detailed per-case evaluation results. + /// Human-readable summary text. + public EvaluationResult(bool passed, List results, string summary) + { + Passed = passed; + Results = results ?? new(); + Summary = summary ?? string.Empty; + } + + /// + /// Creates a failed evaluation result for when the simulation itself failed. + /// + public static EvaluationResult SimulationFailed(string reason) + { + return new EvaluationResult( + false, + new(), + $"Evaluation failed: simulation did not succeed. {reason}" + ); + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/20_Managers/ServiceRegistry.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/EvaluationResult.cs.meta similarity index 83% rename from Assets/10_Scripts/10_Runtime/20_Managers/ServiceRegistry.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/EvaluationResult.cs.meta index 52472ac..57b7cef 100644 --- a/Assets/10_Scripts/10_Runtime/20_Managers/ServiceRegistry.cs.meta +++ b/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/EvaluationResult.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: d14d497a7e2d6fb43b59ea6d54df3891 +guid: 521fb7cad4d47924399234986d88ebc1 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/ObjectiveEvaluator.cs b/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/ObjectiveEvaluator.cs new file mode 100644 index 0000000..f271594 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/ObjectiveEvaluator.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Text; +using CircuitCraft.Simulation; + +namespace CircuitCraft.Core +{ + /// + /// Evaluates simulation results against test case expectations. + /// Pure C# — no Unity dependencies. Lives in the Domain layer. + /// + public class ObjectiveEvaluator + { + /// + /// Evaluates a simulation result against a set of test case inputs. + /// + /// The simulation result to evaluate. + /// Test case inputs (converted from StageTestCase by the caller). + /// An EvaluationResult with pass/fail and per-test details. + public EvaluationResult Evaluate(SimulationResult simResult, TestCaseInput[] testCases) + { + if (simResult is null) + throw new ArgumentNullException(nameof(simResult)); + if (testCases is null) + throw new ArgumentNullException(nameof(testCases)); + + // If simulation itself failed, auto-fail the evaluation + if (!simResult.IsSuccess) + { + return EvaluationResult.SimulationFailed( + simResult.StatusMessage ?? "Unknown simulation failure" + ); + } + + var results = new List(testCases.Length); + bool allPassed = true; + + foreach (var testCase in testCases) + { + var result = EvaluateTestCase(simResult, testCase); + results.Add(result); + + if (!result.Passed) + allPassed = false; + } + + string summary = BuildSummary(allPassed, results); + return new EvaluationResult(allPassed, results, summary); + } + + private TestCaseResult EvaluateTestCase(SimulationResult simResult, TestCaseInput testCase) + { + double? actualVoltage = simResult.GetVoltage(testCase.TestName); + + if (!actualVoltage.HasValue) + { + return new TestCaseResult( + testCase.TestName, + testCase.ExpectedVoltage, + double.NaN, + testCase.Tolerance, + false, + $"No voltage reading found for node '{testCase.TestName}'" + ); + } + + double actual = actualVoltage.Value; + double difference = Math.Abs(actual - testCase.ExpectedVoltage); + bool passed = difference <= testCase.Tolerance; + + string message = passed + ? $"PASS: '{testCase.TestName}' = {actual:F4}V (expected {testCase.ExpectedVoltage:F4}V ±{testCase.Tolerance:F4}V)" + : $"FAIL: '{testCase.TestName}' = {actual:F4}V (expected {testCase.ExpectedVoltage:F4}V ±{testCase.Tolerance:F4}V, off by {difference:F4}V)"; + + return new TestCaseResult( + testCase.TestName, + testCase.ExpectedVoltage, + actual, + testCase.Tolerance, + passed, + message + ); + } + + private static string BuildSummary(bool allPassed, List results) + { + int passCount = 0; + foreach (var r in results) + { + if (r.Passed) passCount++; + } + + var sb = new StringBuilder(); + sb.Append(allPassed ? "PASSED" : "FAILED"); + sb.Append($" ({passCount}/{results.Count} test cases)"); + + foreach (var r in results) + { + sb.AppendLine(); + sb.Append(" "); + sb.Append(r.Message); + } + + return sb.ToString(); + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/50_Data/StageConstraints.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/ObjectiveEvaluator.cs.meta similarity index 83% rename from Assets/10_Scripts/10_Runtime/50_Data/StageConstraints.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/ObjectiveEvaluator.cs.meta index e4f2af0..3e186a6 100644 --- a/Assets/10_Scripts/10_Runtime/50_Data/StageConstraints.cs.meta +++ b/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/ObjectiveEvaluator.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 664830c86a23c96408a8f41e7c558871 +guid: adf29c854a897cf45b28ef5907358cee MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/ScoreBreakdown.cs b/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/ScoreBreakdown.cs new file mode 100644 index 0000000..4f49d02 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/ScoreBreakdown.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; + +namespace CircuitCraft.Core +{ + /// + /// Pure C# DTO carrying all inputs needed for score calculation. + /// The Unity caller constructs this from StageDefinition + BoardState data. + /// + public readonly struct ScoringInput + { + /// Whether the circuit passed all test cases. + public bool CircuitPassed { get; } + + /// Sum of BaseCost for every placed component. + public float TotalComponentCost { get; } + + /// Budget limit from StageDefinition. 0 = no limit (auto-pass). + public float BudgetLimit { get; } + + /// Total board area covered by the placed components. + public int BoardArea { get; } + + /// Target board area used for scoring. + public int TargetArea { get; } + + /// Number of traces (wires) on the board. + public int TraceCount { get; } + + /// + /// Creates an immutable scoring input payload. + /// + /// Whether objective checks succeeded. + /// Total placed component cost. + /// Stage budget limit used for bonus calculation. + /// Used board area for area bonus calculation. + /// Target board area for comparison. + /// Total number of placed traces. + public ScoringInput( + bool circuitPassed, + float totalComponentCost, + float budgetLimit, + int boardArea, + int targetArea, + int traceCount) + { + CircuitPassed = circuitPassed; + TotalComponentCost = totalComponentCost; + BudgetLimit = budgetLimit; + BoardArea = boardArea; + TargetArea = targetArea; + TraceCount = traceCount; + } + } + + /// + /// A single line in the score breakdown (e.g. "Circuit Works: +1000"). + /// + public class ScoreLineItem + { + /// Human-readable label for this score component. + public string Label { get; } + + /// Points awarded (or 0 if not earned). + public int Points { get; } + + /// + /// Creates a single score line item entry. + /// + /// Display label shown in the score breakdown. + /// Points contributed by this line item. + public ScoreLineItem(string label, int points) + { + Label = label ?? throw new ArgumentNullException(nameof(label)); + Points = points; + } + + /// + /// Returns a display-friendly representation of this line item. + /// + /// Label and signed points in a single string. + public override string ToString() => $"{Label}: {(Points >= 0 ? "+" : "")}{Points}"; + } + + /// + /// Immutable result of scoring a completed circuit. + /// Contains point totals, star rating, and a detailed line-item breakdown. + /// + public class ScoreBreakdown + { + /// 1000 if circuit works, 0 otherwise. + public int BaseScore { get; } + + /// +500 if total component cost is within budget. + public int BudgetBonus { get; } + + /// +300 scaled by area ratio versus target area. + public int AreaBonus { get; } + + /// Sum of BaseScore + BudgetBonus + AreaBonus. + public int TotalScore { get; } + + /// 0-3 star rating. + public int Stars { get; } + + /// Whether the circuit passed evaluation. + public bool Passed { get; } + + /// Detailed line-item breakdown of the score. + public List LineItems { get; } + + /// Human-readable summary of the score. + public string Summary { get; } + + /// + /// Creates an immutable score breakdown result. + /// + /// Base pass/fail score contribution. + /// Budget bonus contribution. + /// Area-efficiency bonus contribution. + /// Final total score. + /// Awarded star count. + /// Whether stage objectives were passed. + /// Detailed score line items. + /// Human-readable result summary. + public ScoreBreakdown( + int baseScore, + int budgetBonus, + int areaBonus, + int totalScore, + int stars, + bool passed, + List lineItems, + string summary) + { + BaseScore = baseScore; + BudgetBonus = budgetBonus; + AreaBonus = areaBonus; + TotalScore = totalScore; + Stars = stars; + Passed = passed; + LineItems = lineItems ?? new(); + Summary = summary ?? string.Empty; + } + } +} diff --git a/Assets/00_Core/TutorialInfo/Scripts/Editor/ReadmeEditor.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/ScoreBreakdown.cs.meta similarity index 68% rename from Assets/00_Core/TutorialInfo/Scripts/Editor/ReadmeEditor.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/ScoreBreakdown.cs.meta index f038618..67e826b 100644 --- a/Assets/00_Core/TutorialInfo/Scripts/Editor/ReadmeEditor.cs.meta +++ b/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/ScoreBreakdown.cs.meta @@ -1,8 +1,7 @@ fileFormatVersion: 2 -guid: 476cc7d7cd9874016adc216baab94a0a -timeCreated: 1484146680 -licenseType: Store +guid: 12cf4ce8e97f8c248a22c0f57bb0eb29 MonoImporter: + externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 diff --git a/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/ScoringSystem.cs b/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/ScoringSystem.cs new file mode 100644 index 0000000..b9408c7 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/ScoringSystem.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace CircuitCraft.Core +{ + /// + /// Calculates a 3-star score after a circuit passes evaluation. + /// Pure C# — no Unity dependencies. Lives in the Domain layer. + /// + /// Star logic: + /// 0★ = circuit didn't pass + /// 1★ = circuit works (base score) + /// 2★ = circuit works + (under budget OR within area target) + /// 3★ = circuit works + under budget + within area target + /// + public class ScoringSystem + { + private const int BASE_SCORE = 1000; + private const int BUDGET_BONUS = 500; + private const int AREA_BONUS = 300; + + /// + /// Calculates score breakdown from the given scoring input. + /// + /// Scoring data assembled by the caller from stage + board state. + /// Immutable score breakdown with star rating and line items. + public ScoreBreakdown Calculate(ScoringInput input) + { + var lineItems = new List(); + + // --- Base score --- + int baseScore = 0; + if (input.CircuitPassed) + { + baseScore = BASE_SCORE; + lineItems.Add(new ScoreLineItem("Circuit Works", BASE_SCORE)); + } + else + { + lineItems.Add(new ScoreLineItem("Circuit Failed", 0)); + return BuildResult(baseScore, 0, 0, false, lineItems, false); + } + + // --- Budget bonus --- + bool underBudget = IsUnderBudget(input); + int budgetBonus = 0; + if (underBudget) + { + budgetBonus = BUDGET_BONUS; + if (input.BudgetLimit > 0f) + lineItems.Add(new ScoreLineItem( + $"Under Budget ({input.TotalComponentCost:F0}/{input.BudgetLimit:F0})", + BUDGET_BONUS)); + else + lineItems.Add(new ScoreLineItem("Budget: No Limit", BUDGET_BONUS)); + } + else + { + lineItems.Add(new ScoreLineItem( + $"Over Budget ({input.TotalComponentCost:F0}/{input.BudgetLimit:F0})", + 0)); + } + + // --- Area bonus --- + bool withinTarget = IsWithinAreaTarget(input); + int areaBonus = CalculateAreaBonus(input); + string areaLabel = withinTarget + ? $"Small Footprint ({input.BoardArea}/{input.TargetArea})" + : $"Over Footprint Target ({input.BoardArea}/{input.TargetArea})"; + lineItems.Add(new ScoreLineItem(areaLabel, areaBonus)); + + return BuildResult(baseScore, budgetBonus, areaBonus, true, lineItems, withinTarget); + } + + /// + /// Under budget if no limit is set (0) or cost <= limit. + /// + private static bool IsUnderBudget(ScoringInput input) + { + if (input.BudgetLimit <= 0f) return true; // no limit → auto-pass + return input.TotalComponentCost <= input.BudgetLimit; + } + + /// + /// Within target area if no target is set (0) or board area is within target. + /// + private static bool IsWithinAreaTarget(ScoringInput input) + { + float targetArea = Math.Max(1f, input.TargetArea); + float boardArea = Math.Max(1f, input.BoardArea); + return boardArea <= targetArea; + } + + /// + /// Compute linear area bonus points from board-to-target area ratio. + /// + private static int CalculateAreaBonus(ScoringInput input) + { + float targetArea = Math.Max(1f, input.TargetArea); + float boardArea = Math.Max(1, input.BoardArea); + float areaRatio = boardArea / targetArea; + float areaFactor = Math.Max(0f, Math.Min(1f, 2f - areaRatio)); + return (int)Math.Round(AREA_BONUS * areaFactor); + } + + /// + /// Calculates star count from bonus flags. + /// + private static int CalculateStars(bool passed, bool underBudget, bool withinTarget) + { + if (!passed) return 0; + + int bonusCount = 0; + if (underBudget) bonusCount++; + if (withinTarget) bonusCount++; + + // 1★ base + bonuses (max 3★) + return 1 + bonusCount; + } + + private static ScoreBreakdown BuildResult( + int baseScore, + int budgetBonus, + int areaBonus, + bool passed, + List lineItems, + bool withinTarget) + { + int totalScore = baseScore + budgetBonus + areaBonus; + bool underBudget = budgetBonus > 0; + int stars = CalculateStars(passed, underBudget, withinTarget); + + string summary = BuildSummary(passed, stars, totalScore, lineItems); + + return new ScoreBreakdown( + baseScore, + budgetBonus, + areaBonus, + totalScore, + stars, + passed, + lineItems, + summary); + } + + private static string BuildSummary(bool passed, int stars, int totalScore, List lineItems) + { + var sb = new StringBuilder(); + + if (!passed) + { + sb.Append("FAILED — 0 Stars"); + return sb.ToString(); + } + + sb.Append(stars); + sb.Append(stars == 1 ? " Star" : " Stars"); + sb.Append($" — {totalScore} pts"); + + foreach (var item in lineItems) + { + sb.AppendLine(); + sb.Append(" "); + sb.Append(item.ToString()); + } + + return sb.ToString(); + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/ScoringSystem.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/ScoringSystem.cs.meta new file mode 100644 index 0000000..74427ac --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/30_Scoring/ScoringSystem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1515110bc8eb13248b467d5d99dc3e6a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Simulation.meta b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Simulation.meta rename to Assets/10_Scripts/10_Runtime/10_Core/40_Simulation.meta diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SpiceSharp.meta b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Simulation/SpiceSharp.meta rename to Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp.meta diff --git a/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/AnalysisStrategyUtilities.cs b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/AnalysisStrategyUtilities.cs new file mode 100644 index 0000000..9ee3adf --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/AnalysisStrategyUtilities.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using SpiceSharp.Simulations; + +namespace CircuitCraft.Simulation.SpiceSharp +{ + internal static class AnalysisStrategyUtilities + { + /// + /// Creates SpiceSharp export bindings for all probes in the netlist. + /// + /// Simulation instance that owns export objects. + /// Netlist containing probe definitions. + /// Map of probe IDs to export handles. + public static Dictionary> CreateExports(ISimulation simulation, CircuitNetlist netlist) + { + Dictionary> exports = new(); + var biasingSimulation = simulation as IBiasingSimulation; + var eventfulSimulation = simulation as IEventfulSimulation; + + if (biasingSimulation is null) + throw new InvalidOperationException("Simulation does not support biasing exports."); + + foreach (var probe in netlist.Probes) + { + IExport export = null; + switch (probe.Type) + { + case ProbeType.Voltage: + var refNode = probe.ReferenceNode ?? "0"; + export = refNode == "0" || refNode == netlist.GroundNode + ? new RealVoltageExport(biasingSimulation, probe.Target) + : new RealVoltageExport(biasingSimulation, probe.Target, refNode); + break; + + case ProbeType.Current: + export = new RealCurrentExport(biasingSimulation, probe.Target); + break; + + case ProbeType.Power: + if (eventfulSimulation is null) + throw new InvalidOperationException("Simulation does not support property exports."); + export = new RealPropertyExport(eventfulSimulation, probe.Target, "p"); + break; + } + + if (export is not null) + exports[probe.Id] = export; + } + + return exports; + } + + /// + /// Reads scalar probe exports and appends DC results. + /// + /// Lookup of export objects keyed by probe ID. + /// Probe definitions to evaluate. + /// Result object receiving probe values and warnings. + public static void CollectDCResults( + Dictionary> exports, + List probes, + SimulationResult result) + { + foreach (var probe in probes) + { + if (!exports.TryGetValue(probe.Id, out var export)) + continue; + + try + { + var value = export.Value; + result.ProbeResults.Add(new ProbeResult(probe.Id, probe.Type, probe.Target, value)); + } + catch + { + result.Issues.Add(new SimulationIssue( + IssueSeverity.Warning, + IssueCategory.General, + $"Could not read probe '{probe.Id}' - export failed")); + } + } + } + + /// + /// Captures one sample point for transient or sweep probe series. + /// + /// Lookup of export objects keyed by probe ID. + /// Probe definitions to evaluate. + /// Per-probe sampled values collection. + public static void CollectSeriesPoint( + Dictionary> exports, + List probes, + Dictionary> probeData) + { + foreach (var probe in probes) + { + if (!exports.TryGetValue(probe.Id, out var export)) + continue; + + try + { + probeData[probe.Id].Add(export.Value); + } + catch + { + probeData[probe.Id].Add(double.NaN); + } + } + } + + /// + /// Builds final probe result objects from sampled series data. + /// + /// Probe definitions that were sampled. + /// Per-probe sampled value lists. + /// Shared X-axis points (time or sweep values). + /// Result object receiving aggregated probe outputs. + public static void BuildSeriesResults( + List probes, + Dictionary> probeData, + List xPoints, + SimulationResult result) + { + foreach (var probe in probes) + { + if (!probeData.TryGetValue(probe.Id, out var values) || values.Count == 0) + continue; + + var probeResult = new ProbeResult + { + ProbeId = probe.Id, + Type = probe.Type, + Target = probe.Target, + TimePoints = new(xPoints), + Values = new(values) + }; + + double min = double.MaxValue; + double max = double.MinValue; + double sum = 0; + int validCount = 0; + + foreach (var value in values) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + continue; + + if (value < min) min = value; + if (value > max) max = value; + sum += value; + validCount++; + } + + if (validCount > 0) + { + probeResult.MinValue = min; + probeResult.MaxValue = max; + probeResult.AverageValue = sum / validCount; + probeResult.Value = values[values.Count - 1]; + } + + result.ProbeResults.Add(probeResult); + } + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/AnalysisStrategyUtilities.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/AnalysisStrategyUtilities.cs.meta new file mode 100644 index 0000000..79469bf --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/AnalysisStrategyUtilities.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a5020ca431ec02439e20c9394d16e29 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/DCOperatingPointStrategy.cs b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/DCOperatingPointStrategy.cs new file mode 100644 index 0000000..5f97762 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/DCOperatingPointStrategy.cs @@ -0,0 +1,58 @@ +using System; +using System.Diagnostics; +using System.Threading; +using SpiceSharp; +using SpiceSharp.Simulations; + +namespace CircuitCraft.Simulation.SpiceSharp +{ + /// + /// Runs a DC operating point analysis and maps probe outputs. + /// + public class DCOperatingPointStrategy : IAnalysisStrategy + { + /// + public SimulationResult Execute(Circuit circuit, CircuitNetlist netlist, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var stopwatch = Stopwatch.StartNew(); + var result = SimulationResult.Success(SimulationType.DCOperatingPoint, 0); + + try + { + var op = new OP("op"); + var exports = AnalysisStrategyUtilities.CreateExports(op, netlist); + + op.ExportSimulationData += (sender, args) => + { + cancellationToken.ThrowIfCancellationRequested(); + AnalysisStrategyUtilities.CollectDCResults(exports, netlist.Probes, result); + }; + + cancellationToken.ThrowIfCancellationRequested(); + op.Run(circuit); + + stopwatch.Stop(); + result.ElapsedMilliseconds = stopwatch.Elapsed.TotalMilliseconds; + result.StatusMessage = $"DC operating point completed in {result.ElapsedMilliseconds:F1}ms"; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + stopwatch.Stop(); + result = SimulationResult.Failure( + SimulationType.DCOperatingPoint, + SimulationStatus.Error, + $"Simulation error: {ex.Message}"); + result.ElapsedMilliseconds = stopwatch.Elapsed.TotalMilliseconds; + result.Issues.Add(SimulationIssue.ConvergenceFailure(ex.Message)); + } + + return result; + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/DCOperatingPointStrategy.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/DCOperatingPointStrategy.cs.meta new file mode 100644 index 0000000..3f70c99 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/DCOperatingPointStrategy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3266cc8289a859d4398e91194191a5be +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/DCSweepAnalysisStrategy.cs b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/DCSweepAnalysisStrategy.cs new file mode 100644 index 0000000..cb07a91 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/DCSweepAnalysisStrategy.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using SpiceSharp; +using SpiceSharp.Simulations; + +namespace CircuitCraft.Simulation.SpiceSharp +{ + /// + /// Runs a DC sweep analysis over a configured source range. + /// + public class DCSweepAnalysisStrategy : IAnalysisStrategy + { + private readonly DCSweepConfig _config; + + /// + /// Creates a DC sweep strategy with a required configuration. + /// + /// Sweep configuration defining source and range. + public DCSweepAnalysisStrategy(DCSweepConfig config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + } + + /// + public SimulationResult Execute(Circuit circuit, CircuitNetlist netlist, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var stopwatch = Stopwatch.StartNew(); + var result = SimulationResult.Success(SimulationType.DCSweep, 0); + + try + { + var dc = new DC("dc", _config.SourceId, _config.StartValue, _config.StopValue, _config.StepValue); + var exports = AnalysisStrategyUtilities.CreateExports(dc, netlist); + + Dictionary> probeData = new(); + List sweepPoints = new(); + foreach (var probe in netlist.Probes) + probeData[probe.Id] = new(); + + dc.ExportSimulationData += (sender, args) => + { + cancellationToken.ThrowIfCancellationRequested(); + sweepPoints.Add(dc.GetCurrentSweepValue().First()); + AnalysisStrategyUtilities.CollectSeriesPoint(exports, netlist.Probes, probeData); + }; + + cancellationToken.ThrowIfCancellationRequested(); + dc.Run(circuit); + cancellationToken.ThrowIfCancellationRequested(); + + AnalysisStrategyUtilities.BuildSeriesResults(netlist.Probes, probeData, sweepPoints, result); + + stopwatch.Stop(); + result.ElapsedMilliseconds = stopwatch.Elapsed.TotalMilliseconds; + result.StatusMessage = $"DC sweep completed in {result.ElapsedMilliseconds:F1}ms ({sweepPoints.Count} points)"; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + stopwatch.Stop(); + result = SimulationResult.Failure( + SimulationType.DCSweep, + SimulationStatus.Error, + $"Simulation error: {ex.Message}"); + result.ElapsedMilliseconds = stopwatch.Elapsed.TotalMilliseconds; + result.Issues.Add(SimulationIssue.ConvergenceFailure(ex.Message)); + } + + return result; + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/DCSweepAnalysisStrategy.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/DCSweepAnalysisStrategy.cs.meta new file mode 100644 index 0000000..3173e00 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/DCSweepAnalysisStrategy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 07f39242cac6de549a66ba06994b55ce +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/IAnalysisStrategy.cs b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/IAnalysisStrategy.cs new file mode 100644 index 0000000..1a2f022 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/IAnalysisStrategy.cs @@ -0,0 +1,20 @@ +using System.Threading; +using SpiceSharp; + +namespace CircuitCraft.Simulation.SpiceSharp +{ + /// + /// Executes a specific simulation analysis strategy against a built circuit. + /// + public interface IAnalysisStrategy + { + /// + /// Runs the analysis and returns a mapped simulation result. + /// + /// SpiceSharp circuit instance to simulate. + /// Source netlist metadata and probes. + /// Cancellation token for cooperative abort. + /// Simulation result containing probe values and issues. + SimulationResult Execute(Circuit circuit, CircuitNetlist netlist, CancellationToken cancellationToken); + } +} diff --git a/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/IAnalysisStrategy.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/IAnalysisStrategy.cs.meta new file mode 100644 index 0000000..b5d9ccf --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/IAnalysisStrategy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f6a0c689e3fbcf84aaf943dd7f675b9c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SpiceSharp/NetlistBuilder.cs b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/NetlistBuilder.cs similarity index 91% rename from Assets/10_Scripts/10_Runtime/10_Core/Simulation/SpiceSharp/NetlistBuilder.cs rename to Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/NetlistBuilder.cs index 8764011..60ee4cd 100644 --- a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SpiceSharp/NetlistBuilder.cs +++ b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/NetlistBuilder.cs @@ -21,18 +21,22 @@ public class NetlistBuilder /// If netlist contains invalid elements. public Circuit Build(CircuitNetlist netlist) { - if (netlist == null) + if (netlist is null) throw new ArgumentNullException(nameof(netlist)); var circuit = new Circuit(); + var addedModelNames = new HashSet(); foreach (var element in netlist.Elements) { var entities = CreateEntities(element); foreach (var entity in entities) { - if (entity != null) + if (entity is not null) { + if (IsSemiconductorModelEntity(entity) && !addedModelNames.Add(entity.Name)) + continue; + circuit.Add(entity); } } @@ -41,17 +45,24 @@ public Circuit Build(CircuitNetlist netlist) return circuit; } + private static bool IsSemiconductorModelEntity(IEntity entity) + { + return entity is DiodeModel + or BipolarJunctionTransistorModel + or Mosfet1Model; + } + /// /// Creates SpiceSharp entities from a domain element definition. /// For semiconductors, may return both component and model. /// private IEnumerable CreateEntities(NetlistElement element) { - if (element == null || string.IsNullOrEmpty(element.Id)) + if (element is null || string.IsNullOrEmpty(element.Id)) yield break; // Validate we have enough nodes - if (element.Nodes == null || element.Nodes.Count < 2) + if (element.Nodes is null || element.Nodes.Count < 2) { throw new InvalidOperationException( $"Element '{element.Id}' must have at least 2 nodes"); @@ -112,7 +123,7 @@ private IEnumerable CreateEntities(NetlistElement element) /// private IEntity CreateComponent(NetlistElement element) { - var entities = new List(CreateEntities(element)); + List entities = new(CreateEntities(element)); return entities.Count > 0 ? entities[0] : null; } @@ -171,6 +182,10 @@ private IEnumerable CreateDiode(NetlistElement element) model.Parameters.SaturationCurrent = satCurrent; if (element.Parameters.TryGetValue("N", out var emissionCoeff)) model.Parameters.EmissionCoefficient = emissionCoeff; + if (element.Parameters.TryGetValue("BV", out var breakdownVoltage)) + model.Parameters.BreakdownVoltage = breakdownVoltage; + if (element.Parameters.TryGetValue("IBV", out var breakdownCurrent)) + model.Parameters.BreakdownCurrent = breakdownCurrent; yield return model; } @@ -242,15 +257,15 @@ private IEnumerable CreateMOSFET(NetlistElement element) /// List of validation issues found. public List Validate(CircuitNetlist netlist) { - var issues = new List(); + List issues = new(); - if (netlist == null) + if (netlist is null) { issues.Add(new SimulationIssue(IssueSeverity.Error, IssueCategory.General, "Netlist is null")); return issues; } - if (netlist.Elements == null || netlist.Elements.Count == 0) + if (netlist.Elements is null || netlist.Elements.Count == 0) { issues.Add(new SimulationIssue(IssueSeverity.Error, IssueCategory.Topology, "Netlist has no elements")); return issues; @@ -290,7 +305,7 @@ public List Validate(CircuitNetlist netlist) continue; } - if (element.Nodes == null || element.Nodes.Count < 2) + if (element.Nodes is null || element.Nodes.Count < 2) { issues.Add(new SimulationIssue(IssueSeverity.Error, IssueCategory.Topology, $"Element '{element.Id}' has fewer than 2 nodes", element.Id)); diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SpiceSharp/NetlistBuilder.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/NetlistBuilder.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Simulation/SpiceSharp/NetlistBuilder.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/NetlistBuilder.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/SimulationRunner.cs b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/SimulationRunner.cs new file mode 100644 index 0000000..77b0412 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/SimulationRunner.cs @@ -0,0 +1,125 @@ +using System; +using System.Threading; +using Cysharp.Threading.Tasks; +using SpiceSharp; + +namespace CircuitCraft.Simulation.SpiceSharp +{ + /// + /// Runs SpiceSharp simulations and collects probe data. + /// Infrastructure component - handles SpiceSharp simulation execution. + /// + public class SimulationRunner + { + private readonly NetlistBuilder _netlistBuilder; + + /// + /// Creates a new simulation runner with a netlist builder. + /// + public SimulationRunner(NetlistBuilder netlistBuilder = null) + { + _netlistBuilder = netlistBuilder ?? new(); + } + + /// + /// Runs a DC operating point simulation. + /// + /// The SpiceSharp circuit to simulate. + /// The domain netlist (for probe definitions). + /// Optional cancellation token. + /// Simulation result with probe measurements. + public async UniTask RunDCOperatingPointAsync( + Circuit circuit, + CircuitNetlist netlist, + CancellationToken cancellationToken = default) + { + var strategy = new DCOperatingPointStrategy(); + return await UniTask.RunOnThreadPool( + () => strategy.Execute(circuit, netlist, cancellationToken), + cancellationToken: cancellationToken); + } + + /// + /// Runs a transient simulation. + /// + /// The SpiceSharp circuit to simulate. + /// The domain netlist (for probe definitions). + /// Transient simulation configuration. + /// Optional cancellation token. + /// Simulation result with probe measurements over time. + public async UniTask RunTransientAsync( + Circuit circuit, + CircuitNetlist netlist, + TransientConfig config, + CancellationToken cancellationToken = default) + { + var strategy = new TransientAnalysisStrategy(config); + return await UniTask.RunOnThreadPool( + () => strategy.Execute(circuit, netlist, cancellationToken), + cancellationToken: cancellationToken); + } + + /// + /// Runs a DC sweep simulation. + /// + /// The SpiceSharp circuit to simulate. + /// The domain netlist (for probe definitions). + /// DC sweep configuration. + /// Optional cancellation token. + /// Simulation result with probe measurements at each sweep point. + public async UniTask RunDCSweepAsync( + Circuit circuit, + CircuitNetlist netlist, + DCSweepConfig config, + CancellationToken cancellationToken = default) + { + var strategy = new DCSweepAnalysisStrategy(config); + return await UniTask.RunOnThreadPool( + () => strategy.Execute(circuit, netlist, cancellationToken), + cancellationToken: cancellationToken); + } + + /// + /// Checks for overcurrent and overpower conditions. + /// + /// The circuit netlist with element limits. + /// The simulation result to check and add issues to. + public void CheckSafetyLimits(CircuitNetlist netlist, SimulationResult result) + { + foreach (var element in netlist.Elements) + { + // Check overcurrent + if (element.MaxCurrentAmps.HasValue) + { + var current = result.GetCurrent(element.Id); + if (current.HasValue && Math.Abs(current.Value) > element.MaxCurrentAmps.Value) + { + result.Issues.Add(SimulationIssue.Overcurrent( + element.Id, Math.Abs(current.Value), element.MaxCurrentAmps.Value)); + } + } + + // Check overpower for resistors + if (element.Type == ElementType.Resistor && element.MaxPowerWatts.HasValue) + { + var current = result.GetCurrent(element.Id); + if (current.HasValue) + { + var power = current.Value * current.Value * element.Value; // P = I²R + if (power > element.MaxPowerWatts.Value) + { + result.Issues.Add(SimulationIssue.Overpower( + element.Id, power, element.MaxPowerWatts.Value)); + } + } + } + } + + // Update status if we found errors + if (result.HasErrors && result.Status == SimulationStatus.Success) + { + result.Status = SimulationStatus.CompletedWithWarnings; + } + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SpiceSharp/SimulationRunner.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/SimulationRunner.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Simulation/SpiceSharp/SimulationRunner.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/SimulationRunner.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SpiceSharp/SpiceSharpSimulationService.cs b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/SpiceSharpSimulationService.cs similarity index 94% rename from Assets/10_Scripts/10_Runtime/10_Core/Simulation/SpiceSharp/SpiceSharpSimulationService.cs rename to Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/SpiceSharpSimulationService.cs index edf4441..24be921 100644 --- a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SpiceSharp/SpiceSharpSimulationService.cs +++ b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/SpiceSharpSimulationService.cs @@ -19,7 +19,7 @@ public class SpiceSharpSimulationService : ISimulationService /// public SpiceSharpSimulationService() { - _netlistBuilder = new NetlistBuilder(); + _netlistBuilder = new(); _simulationRunner = new SimulationRunner(_netlistBuilder); } @@ -30,8 +30,8 @@ public SpiceSharpSimulationService() /// Custom simulation runner. public SpiceSharpSimulationService(NetlistBuilder netlistBuilder, SimulationRunner simulationRunner) { - _netlistBuilder = netlistBuilder ?? new NetlistBuilder(); - _simulationRunner = simulationRunner ?? new SimulationRunner(_netlistBuilder); + _netlistBuilder = netlistBuilder ?? new(); + _simulationRunner = simulationRunner ?? new(_netlistBuilder); } /// @@ -39,13 +39,13 @@ public async UniTask RunAsync(SimulationRequest request, Cance { cancellationToken.ThrowIfCancellationRequested(); - if (request == null) + if (request is null) { return SimulationResult.Failure(SimulationType.DCOperatingPoint, SimulationStatus.InvalidCircuit, "Request is null"); } - if (request.Netlist == null) + if (request.Netlist is null) { return SimulationResult.Failure(request.SimulationType, SimulationStatus.InvalidCircuit, "Netlist is null"); @@ -74,7 +74,7 @@ public async UniTask RunAsync(SimulationRequest request, Cance break; case SimulationType.Transient: - if (request.TransientConfig == null) + if (request.TransientConfig is null) { return SimulationResult.Failure(SimulationType.Transient, SimulationStatus.InvalidCircuit, "Transient config is required for transient analysis"); @@ -87,7 +87,7 @@ public async UniTask RunAsync(SimulationRequest request, Cance break; case SimulationType.DCSweep: - if (request.DCSweepConfig == null) + if (request.DCSweepConfig is null) { return SimulationResult.Failure(SimulationType.DCSweep, SimulationStatus.InvalidCircuit, "DC Sweep config is required"); diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SpiceSharp/SpiceSharpSimulationService.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/SpiceSharpSimulationService.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Simulation/SpiceSharp/SpiceSharpSimulationService.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/SpiceSharpSimulationService.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/TransientAnalysisStrategy.cs b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/TransientAnalysisStrategy.cs new file mode 100644 index 0000000..b9251fc --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/TransientAnalysisStrategy.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using SpiceSharp; +using SpiceSharp.Simulations; + +namespace CircuitCraft.Simulation.SpiceSharp +{ + /// + /// Runs transient time-domain analysis with configured timing parameters. + /// + public class TransientAnalysisStrategy : IAnalysisStrategy + { + private readonly TransientConfig _config; + + /// + /// Creates a transient analysis strategy with a required configuration. + /// + /// Transient timing configuration. + public TransientAnalysisStrategy(TransientConfig config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + } + + /// + public SimulationResult Execute(Circuit circuit, CircuitNetlist netlist, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var stopwatch = Stopwatch.StartNew(); + var result = SimulationResult.Success(SimulationType.Transient, 0); + + try + { + var step = _config.MaxStep > 0 ? _config.MaxStep : _config.StopTime / 100; + var tran = new Transient("tran", step, _config.StopTime); + var exports = AnalysisStrategyUtilities.CreateExports(tran, netlist); + + Dictionary> probeData = new(); + List timePoints = new(); + foreach (var probe in netlist.Probes) + probeData[probe.Id] = new(); + + tran.ExportSimulationData += (sender, args) => + { + cancellationToken.ThrowIfCancellationRequested(); + timePoints.Add(args.Time); + AnalysisStrategyUtilities.CollectSeriesPoint(exports, netlist.Probes, probeData); + }; + + cancellationToken.ThrowIfCancellationRequested(); + tran.Run(circuit); + cancellationToken.ThrowIfCancellationRequested(); + + AnalysisStrategyUtilities.BuildSeriesResults(netlist.Probes, probeData, timePoints, result); + + stopwatch.Stop(); + result.ElapsedMilliseconds = stopwatch.Elapsed.TotalMilliseconds; + result.StatusMessage = $"Transient completed in {result.ElapsedMilliseconds:F1}ms ({timePoints.Count} points)"; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + stopwatch.Stop(); + result = SimulationResult.Failure( + SimulationType.Transient, + SimulationStatus.Error, + $"Simulation error: {ex.Message}"); + result.ElapsedMilliseconds = stopwatch.Elapsed.TotalMilliseconds; + result.Issues.Add(SimulationIssue.ConvergenceFailure(ex.Message)); + } + + return result; + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/TransientAnalysisStrategy.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/TransientAnalysisStrategy.cs.meta new file mode 100644 index 0000000..9ca6f42 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/10_SpiceSharp/TransientAnalysisStrategy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b051daabab735b449800f45f4ba13d96 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/CircuitNetlist.cs b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/CircuitNetlist.cs similarity index 83% rename from Assets/10_Scripts/10_Runtime/10_Core/Simulation/CircuitNetlist.cs rename to Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/CircuitNetlist.cs index a00f7eb..70f1f46 100644 --- a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/CircuitNetlist.cs +++ b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/CircuitNetlist.cs @@ -32,7 +32,7 @@ public class NetlistElement public ElementType Type { get; set; } /// Node names this element connects to. - public List Nodes { get; set; } = new List(); + public List Nodes { get; set; } = new(); /// Primary value (resistance, capacitance, voltage, etc.). public double Value { get; set; } @@ -41,7 +41,7 @@ public class NetlistElement public string ModelName { get; set; } /// Optional additional parameters. - public Dictionary Parameters { get; set; } = new Dictionary(); + public Dictionary Parameters { get; set; } = new(); /// Maximum rated current in amps (for safety checking). public double? MaxCurrentAmps { get; set; } @@ -49,8 +49,18 @@ public class NetlistElement /// Maximum rated power in watts (for safety checking). public double? MaxPowerWatts { get; set; } + /// + /// Creates an empty netlist element for serialization. + /// public NetlistElement() { } + /// + /// Creates a netlist element with identifier, type, value, and nodes. + /// + /// Unique element identifier. + /// Element type used by simulation backends. + /// Primary element value. + /// Node names this element is connected to. public NetlistElement(string id, ElementType type, double value, params string[] nodes) { Id = id; @@ -67,7 +77,7 @@ public static NetlistElement Resistor(string id, string nodeA, string nodeB, dou Id = id, Type = ElementType.Resistor, Value = ohms, - Nodes = new List { nodeA, nodeB }, + Nodes = new() { nodeA, nodeB }, MaxPowerWatts = maxPowerWatts }; } @@ -80,7 +90,7 @@ public static NetlistElement Capacitor(string id, string nodeA, string nodeB, do Id = id, Type = ElementType.Capacitor, Value = farads, - Nodes = new List { nodeA, nodeB } + Nodes = new() { nodeA, nodeB } }; } @@ -92,7 +102,7 @@ public static NetlistElement VoltageSource(string id, string nodePositive, strin Id = id, Type = ElementType.VoltageSource, Value = volts, - Nodes = new List { nodePositive, nodeNegative } + Nodes = new() { nodePositive, nodeNegative } }; } @@ -104,7 +114,7 @@ public static NetlistElement CurrentSource(string id, string nodePositive, strin Id = id, Type = ElementType.CurrentSource, Value = amps, - Nodes = new List { nodePositive, nodeNegative } + Nodes = new() { nodePositive, nodeNegative } }; } @@ -115,10 +125,14 @@ public static NetlistElement CurrentSource(string id, string nodePositive, strin /// Model name (default: "D1N4148"). /// Is parameter - saturation current in amps. /// N parameter - emission coefficient. + /// BV parameter - reverse breakdown voltage in volts. + /// IBV parameter - reverse breakdown current in amps. public static NetlistElement Diode(string id, string anode, string cathode, string modelName = "D1N4148", double? saturationCurrent = null, - double? emissionCoefficient = null) + double? emissionCoefficient = null, + double? breakdownVoltage = null, + double? breakdownCurrent = null) { var element = new NetlistElement { @@ -126,13 +140,17 @@ public static NetlistElement Diode(string id, string anode, string cathode, Type = ElementType.Diode, Value = 0, // Diode uses model, not value ModelName = modelName, - Nodes = new List { anode, cathode } + Nodes = new() { anode, cathode } }; if (saturationCurrent.HasValue) element.Parameters["Is"] = saturationCurrent.Value; if (emissionCoefficient.HasValue) element.Parameters["N"] = emissionCoefficient.Value; + if (breakdownVoltage.HasValue) + element.Parameters["BV"] = breakdownVoltage.Value; + if (breakdownCurrent.HasValue) + element.Parameters["IBV"] = breakdownCurrent.Value; return element; } @@ -158,7 +176,7 @@ public static NetlistElement BJT(string id, string collector, string base_, stri Type = ElementType.BJT, Value = isNPN ? 1 : -1, // Use value to store polarity ModelName = modelName, - Nodes = new List { collector, base_, emitter } + Nodes = new() { collector, base_, emitter } }; if (beta.HasValue) @@ -191,7 +209,7 @@ public static NetlistElement MOSFET(string id, string drain, string gate, string Type = ElementType.MOSFET, Value = isNChannel ? 1 : -1, // Use value to store polarity ModelName = modelName, - Nodes = new List { drain, gate, source, bulk } + Nodes = new() { drain, gate, source, bulk } }; if (thresholdVoltage.HasValue) @@ -221,8 +239,18 @@ public class ProbeDefinition /// Optional reference node for voltage measurements. public string ReferenceNode { get; set; } + /// + /// Creates an empty probe definition for serialization. + /// public ProbeDefinition() { } + /// + /// Creates a probe definition for a specific target. + /// + /// Unique probe identifier. + /// Measurement type the probe should capture. + /// Node or element ID to measure. + /// Reference node used for differential voltage probes. public ProbeDefinition(string id, ProbeType type, string target, string referenceNode = null) { Id = id; @@ -280,12 +308,12 @@ public class CircuitNetlist /// /// List of circuit elements (resistors, sources, etc.). /// - public List Elements { get; set; } = new List(); + public List Elements { get; set; } = new(); /// /// List of probes defining what to measure. /// - public List Probes { get; set; } = new List(); + public List Probes { get; set; } = new(); /// /// Ground node name (default is "0"). diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/CircuitNetlist.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/CircuitNetlist.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Simulation/CircuitNetlist.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/CircuitNetlist.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/ISimulationService.cs b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/ISimulationService.cs similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Simulation/ISimulationService.cs rename to Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/ISimulationService.cs diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/ISimulationService.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/ISimulationService.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Simulation/ISimulationService.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/ISimulationService.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SimulationIssue.cs b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/SimulationIssue.cs similarity index 96% rename from Assets/10_Scripts/10_Runtime/10_Core/Simulation/SimulationIssue.cs rename to Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/SimulationIssue.cs index 8843d75..de32e6c 100644 --- a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SimulationIssue.cs +++ b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/SimulationIssue.cs @@ -144,6 +144,10 @@ public static SimulationIssue ConvergenceFailure(string details = null) }; } + /// + /// Returns a severity-prefixed display string for this issue. + /// + /// Formatted issue string including severity and message. public override string ToString() { var prefix = Severity == IssueSeverity.Error ? "[ERROR]" diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SimulationIssue.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/SimulationIssue.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Simulation/SimulationIssue.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/SimulationIssue.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SimulationRequest.cs b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/SimulationRequest.cs similarity index 81% rename from Assets/10_Scripts/10_Runtime/10_Core/Simulation/SimulationRequest.cs rename to Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/SimulationRequest.cs index 0e92c6e..34ec52d 100644 --- a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SimulationRequest.cs +++ b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/SimulationRequest.cs @@ -38,8 +38,16 @@ public class TransientConfig /// Use initial conditions from DC operating point. public bool IsUsingInitialConditions { get; set; } = true; + /// + /// Creates a transient configuration with default timing values. + /// public TransientConfig() { } + /// + /// Creates a transient configuration with explicit stop and max-step values. + /// + /// End time of the transient simulation in seconds. + /// Maximum integration step size in seconds. public TransientConfig(double stopTime, double maxStep = 0) { StopTime = stopTime; @@ -65,8 +73,18 @@ public class DCSweepConfig /// Step increment for sweep. public double StepValue { get; set; } + /// + /// Creates an empty DC sweep configuration for serialization. + /// public DCSweepConfig() { } + /// + /// Creates a DC sweep configuration for a source and value range. + /// + /// Source ID to sweep. + /// Start value of the sweep range. + /// End value of the sweep range. + /// Increment applied between sweep points. public DCSweepConfig(string sourceId, double start, double stop, double step) { SourceId = sourceId; @@ -118,6 +136,9 @@ public class SimulationRequest /// public string Tag { get; set; } + /// + /// Creates an empty simulation request for serialization. + /// public SimulationRequest() { } /// diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SimulationRequest.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/SimulationRequest.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Simulation/SimulationRequest.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/SimulationRequest.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SimulationResult.cs b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/SimulationResult.cs similarity index 91% rename from Assets/10_Scripts/10_Runtime/10_Core/Simulation/SimulationResult.cs rename to Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/SimulationResult.cs index 70db59a..1b5c2b2 100644 --- a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SimulationResult.cs +++ b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/SimulationResult.cs @@ -28,13 +28,13 @@ public class ProbeResult /// Time series data for transient simulations. /// Empty for DC operating point. /// - public List TimePoints { get; set; } = new List(); + public List TimePoints { get; set; } = new(); /// /// Value series data for transient simulations. /// Empty for DC operating point. /// - public List Values { get; set; } = new List(); + public List Values { get; set; } = new(); /// Minimum value observed (for transient). public double MinValue { get; set; } @@ -45,8 +45,18 @@ public class ProbeResult /// Average/RMS value (for transient). public double AverageValue { get; set; } + /// + /// Creates an empty probe result for serialization. + /// public ProbeResult() { } + /// + /// Creates a scalar probe result. + /// + /// Probe identifier from the request. + /// Measured quantity type. + /// Measured node or element identifier. + /// Measured value. public ProbeResult(string probeId, ProbeType type, string target, double value) { ProbeId = probeId; @@ -146,12 +156,12 @@ public class SimulationResult /// /// Probe measurement results. /// - public List ProbeResults { get; set; } = new List(); + public List ProbeResults { get; set; } = new(); /// /// Issues, warnings, and errors encountered. /// - public List Issues { get; set; } = new List(); + public List Issues { get; set; } = new(); /// /// Optional tag from the request. diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SimulationResult.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/SimulationResult.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Core/Simulation/SimulationResult.cs.meta rename to Assets/10_Scripts/10_Runtime/10_Core/40_Simulation/SimulationResult.cs.meta diff --git a/Assets/20_Prefabs/UI.meta b/Assets/10_Scripts/10_Runtime/10_Core/50_Validation.meta similarity index 77% rename from Assets/20_Prefabs/UI.meta rename to Assets/10_Scripts/10_Runtime/10_Core/50_Validation.meta index dd22591..09ea0ed 100644 --- a/Assets/20_Prefabs/UI.meta +++ b/Assets/10_Scripts/10_Runtime/10_Core/50_Validation.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: aef5de8435e3c75408463c34c12d1c67 +guid: 38b83dbca6e8c90418850360e7422e91 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Assets/10_Scripts/10_Runtime/10_Core/50_Validation/DRCChecker.cs b/Assets/10_Scripts/10_Runtime/10_Core/50_Validation/DRCChecker.cs new file mode 100644 index 0000000..26ff444 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/50_Validation/DRCChecker.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; + +namespace CircuitCraft.Core +{ + /// + /// Performs design rule checks on a board state. + /// Detects shorts (overlapping nets) and unconnected pins. + /// Pure domain logic — no Unity dependencies. + /// + public class DRCChecker + { + private readonly Dictionary> _positionToNets = + new(); + + /// + /// Runs all design rule checks on the given board state. + /// + /// The board state to check. + /// A DRCResult containing all detected violations. + public DRCResult Check(BoardState board) + { + if (board is null) + throw new ArgumentNullException(nameof(board)); + + var violations = new List(); + + DetectShorts(board, violations); + DetectUnconnectedPins(board, violations); + + return new DRCResult(violations); + } + + /// + /// Detects shorts: two different nets with traces passing through the same grid position. + /// Walks each trace segment from Start to End, building a position-to-netIds map. + /// Any position occupied by 2+ different nets is a short. + /// + private void DetectShorts(BoardState board, List violations) + { + // Map each grid position to the set of net IDs that pass through it + _positionToNets.Clear(); + + foreach (var trace in board.Traces) + { + EnumerateTracePositions(trace, position => + { + if (!_positionToNets.TryGetValue(position, out var netIds)) + { + netIds = new(); + _positionToNets[position] = netIds; + } + netIds.Add(trace.NetId); + }); + } + + // Any position with 2+ different net IDs is a short + foreach (var kvp in _positionToNets) + { + if (kvp.Value.Count >= 2) + { + var netIdList = new List(kvp.Value); + netIdList.Sort(); + var netNames = new List(); + foreach (var netId in netIdList) + { + var net = board.GetNet(netId); + netNames.Add(net is not null ? net.NetName : $"Net{netId}"); + } + + violations.Add(new DRCViolationItem( + DRCViolationType.Short, + kvp.Key, + $"Short: nets [{string.Join(", ", netNames)}] overlap at {kvp.Key}" + )); + } + } + + foreach (var kvp in _positionToNets) + { + kvp.Value.Clear(); + } + + _positionToNets.Clear(); + } + + /// + /// Detects unconnected pins: pins on placed components that are not connected to any net. + /// + private void DetectUnconnectedPins(BoardState board, List violations) + { + foreach (var component in board.Components) + { + foreach (var pin in component.Pins) + { + if (!pin.ConnectedNetId.HasValue) + { + var worldPos = component.GetPinWorldPosition(pin.PinIndex); + violations.Add(new DRCViolationItem( + DRCViolationType.UnconnectedPin, + worldPos, + $"Unconnected pin: {pin.PinName} (index {pin.PinIndex}) on component {component.ComponentDefinitionId} (instance {component.InstanceId})" + )); + } + } + } + } + + /// + /// Enumerates all grid positions along a Manhattan (orthogonal) trace segment, + /// including both Start and End positions. + /// + /// The trace segment to walk. + /// Action invoked for each grid position on the trace. + private void EnumerateTracePositions(TraceSegment trace, Action action) + { + var start = trace.Start; + var end = trace.End; + + int dx = end.X > start.X ? 1 : (end.X < start.X ? -1 : 0); + int dy = end.Y > start.Y ? 1 : (end.Y < start.Y ? -1 : 0); + + int x = start.X; + int y = start.Y; + + while (true) + { + action(new GridPosition(x, y)); + + if (x == end.X && y == end.Y) + break; + + x += dx; + y += dy; + } + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/10_Core/50_Validation/DRCChecker.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/50_Validation/DRCChecker.cs.meta new file mode 100644 index 0000000..f1a0679 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/50_Validation/DRCChecker.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f5d393731eecfc842a80dec40568c448 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/10_Core/50_Validation/DRCResult.cs b/Assets/10_Scripts/10_Runtime/10_Core/50_Validation/DRCResult.cs new file mode 100644 index 0000000..841888e --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/50_Validation/DRCResult.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; + +namespace CircuitCraft.Core +{ + /// + /// Types of design rule violations. + /// + public enum DRCViolationType + { + /// Two different nets overlap at the same grid position. + Short, + + /// A pin is not connected to any net. + UnconnectedPin + } + + /// + /// A single design rule violation with type, location, and description. + /// + public class DRCViolationItem + { + /// Gets the type of violation. + public DRCViolationType ViolationType { get; } + + /// Gets the grid position where the violation occurs. + public GridPosition Location { get; } + + /// Gets a human-readable description of the violation. + public string Message { get; } + + /// + /// Creates a new DRC violation item. + /// + /// Type of violation. + /// Grid position of the violation. + /// Description of the violation. + public DRCViolationItem(DRCViolationType violationType, GridPosition location, string message) + { + ViolationType = violationType; + Location = location; + Message = message; + } + + /// + /// Returns a string representation of this violation. + /// + public override string ToString() + { + return $"[{ViolationType}] {Location}: {Message}"; + } + } + + /// + /// Result of a design rule check containing all detected violations. + /// + public class DRCResult + { + private readonly List _violations; + + /// Gets the read-only list of all violations. + public IReadOnlyList Violations { get; } + + /// Gets whether any violations were detected. + public bool HasViolations => _violations.Count > 0; + + /// Gets the number of short violations. + public int ShortCount { get; } + + /// Gets the number of unconnected pin violations. + public int UnconnectedCount { get; } + + /// + /// Creates a new DRC result. + /// + /// List of detected violations. + public DRCResult(List violations) + { + _violations = violations ?? new(); + Violations = _violations.AsReadOnly(); + + int shortCount = 0; + int unconnectedCount = 0; + for (int i = 0; i < _violations.Count; i++) + { + if (_violations[i].ViolationType == DRCViolationType.Short) + shortCount++; + else if (_violations[i].ViolationType == DRCViolationType.UnconnectedPin) + unconnectedCount++; + } + + ShortCount = shortCount; + UnconnectedCount = unconnectedCount; + } + + /// + /// Returns a string summary of the DRC result. + /// + public override string ToString() + { + if (!HasViolations) + return "DRC: No violations"; + + return $"DRC: {_violations.Count} violation(s) ({ShortCount} shorts, {UnconnectedCount} unconnected pins)"; + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/10_Core/50_Validation/DRCResult.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/50_Validation/DRCResult.cs.meta new file mode 100644 index 0000000..174d717 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/50_Validation/DRCResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 29ca4d546dfed204abc0ba959256cb0c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Board/BoardBounds.cs b/Assets/10_Scripts/10_Runtime/10_Core/Board/BoardBounds.cs deleted file mode 100644 index b7b918c..0000000 --- a/Assets/10_Scripts/10_Runtime/10_Core/Board/BoardBounds.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System; - -namespace CircuitCraft.Core -{ - /// - /// Represents the boundaries of the circuit board grid. - /// - public readonly struct BoardBounds : IEquatable - { - /// Gets the width of the board in grid cells. - public int Width { get; } - - /// Gets the height of the board in grid cells. - public int Height { get; } - - /// - /// Creates new board bounds. - /// - /// Width in grid cells. - /// Height in grid cells. - public BoardBounds(int width, int height) - { - if (width <= 0) - throw new ArgumentOutOfRangeException(nameof(width), "Width must be positive."); - if (height <= 0) - throw new ArgumentOutOfRangeException(nameof(height), "Height must be positive."); - - Width = width; - Height = height; - } - - /// - /// Checks if a grid position is within the board bounds. - /// - /// Position to check. - /// True if position is within bounds. - public bool Contains(GridPosition pos) - { - return pos.X >= 0 && pos.X < Width && - pos.Y >= 0 && pos.Y < Height; - } - - /// - /// Checks equality with another board bounds. - /// - public bool Equals(BoardBounds other) - { - return Width == other.Width && Height == other.Height; - } - - /// - /// Checks equality with an object. - /// - public override bool Equals(object obj) - { - return obj is BoardBounds other && Equals(other); - } - - /// - /// Gets the hash code. - /// - public override int GetHashCode() - { - unchecked - { - return (Width * 397) ^ Height; - } - } - - /// - /// Equality operator. - /// - public static bool operator ==(BoardBounds left, BoardBounds right) - { - return left.Equals(right); - } - - /// - /// Inequality operator. - /// - public static bool operator !=(BoardBounds left, BoardBounds right) - { - return !left.Equals(right); - } - - /// - /// Returns a string representation. - /// - public override string ToString() - { - return $"{Width}x{Height}"; - } - } -} diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Board/BoardState.cs b/Assets/10_Scripts/10_Runtime/10_Core/Board/BoardState.cs deleted file mode 100644 index ca762cd..0000000 --- a/Assets/10_Scripts/10_Runtime/10_Core/Board/BoardState.cs +++ /dev/null @@ -1,229 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace CircuitCraft.Core -{ - /// - /// Represents the complete state of the circuit board. - /// This is the central domain model containing all placed components and nets. - /// - public class BoardState - { - private readonly List _components = new List(); - private readonly Dictionary _componentsByPosition = new Dictionary(); - private readonly List _nets = new List(); - private readonly IReadOnlyList _readOnlyComponents; - private int _nextComponentId = 1; - private int _nextNetId = 1; - - /// Gets the board boundaries. - public BoardBounds Bounds { get; } - - /// Gets the read-only list of placed components. - public IReadOnlyList Components => _readOnlyComponents; - - /// Gets the read-only list of nets. - public IReadOnlyList Nets => _nets.AsReadOnly(); - - /// Event raised when a component is placed on the board. - public event Action OnComponentPlaced; - - /// Event raised when a component is removed from the board. - public event Action OnComponentRemoved; - - /// Event raised when a net is created. - public event Action OnNetCreated; - - /// Event raised when two pins are connected via a net. - public event Action OnPinsConnected; - - /// - /// Creates a new board state. - /// - /// Board width in grid cells. - /// Board height in grid cells. - public BoardState(int width, int height) - { - Bounds = new BoardBounds(width, height); - _readOnlyComponents = _components.AsReadOnly(); - } - - /// - /// Places a component on the board. - /// - /// Component definition ID. - /// Position on the board. - /// Rotation (0, 90, 180, 270). - /// Pin instances for this component. - /// The placed component. - public PlacedComponent PlaceComponent(string componentDefId, GridPosition position, - int rotation, IEnumerable pins) - { - if (!Bounds.Contains(position)) - throw new ArgumentException($"Position {position} is outside board bounds."); - - if (_componentsByPosition.ContainsKey(position)) - throw new InvalidOperationException($"Position {position} is already occupied."); - - var instanceId = _nextComponentId++; - var component = new PlacedComponent(instanceId, componentDefId, position, rotation, pins); - _components.Add(component); - _componentsByPosition.Add(position, component); - - OnComponentPlaced?.Invoke(component); - return component; - } - - /// - /// Removes a component from the board. - /// - /// Component instance ID. - /// True if component was removed. - public bool RemoveComponent(int instanceId) - { - var component = _components.FirstOrDefault(c => c.InstanceId == instanceId); - if (component == null) - return false; - - // Remove component's pins from all nets - var netsToCheck = new List(); - foreach (var net in _nets) - { - var pinsToRemove = net.ConnectedPins - .Where(p => p.ComponentInstanceId == instanceId) - .ToList(); - foreach (var pin in pinsToRemove) - { - net.RemovePin(pin); - } - - // Track nets that might now be empty - if (pinsToRemove.Count > 0) - { - netsToCheck.Add(net); - } - } - - // Remove empty nets - foreach (var net in netsToCheck) - { - if (net.ConnectedPins.Count == 0) - { - _nets.Remove(net); - } - } - - _components.Remove(component); - _componentsByPosition.Remove(component.Position); - OnComponentRemoved?.Invoke(instanceId); - return true; - } - - /// - /// Creates a new net. - /// - /// Name for the net (e.g., "VIN", "GND"). - /// The created net. - public Net CreateNet(string netName) - { - var netId = _nextNetId++; - var net = new Net(netId, netName); - _nets.Add(net); - - OnNetCreated?.Invoke(net); - return net; - } - - /// - /// Connects a pin to a net. - /// - /// Net ID. - /// Pin reference to connect. - public void ConnectPinToNet(int netId, PinReference pin) - { - var net = GetNet(netId); - if (net == null) - throw new ArgumentException($"Net {netId} not found.", nameof(netId)); - - // Update component's pin instance - var component = GetComponent(pin.ComponentInstanceId); - if (component == null) - throw new ArgumentException($"Component {pin.ComponentInstanceId} not found."); - - var pinInstance = component.Pins.FirstOrDefault(p => p.PinIndex == pin.PinIndex); - if (pinInstance == null) - throw new ArgumentException($"Pin {pin.PinIndex} not found on component {pin.ComponentInstanceId}."); - - pinInstance.ConnectedNetId = netId; - net.AddPin(pin); - - // Check if this creates a connection between two pins - var connectedPins = net.ConnectedPins; - if (connectedPins.Count >= 2) - { - var otherPin = connectedPins[connectedPins.Count - 2]; - OnPinsConnected?.Invoke(netId, otherPin, pin); - } - } - - /// - /// Gets a component by instance ID. - /// - /// Component instance ID. - /// The component, or null if not found. - public PlacedComponent GetComponent(int instanceId) - { - return _components.FirstOrDefault(c => c.InstanceId == instanceId); - } - - /// - /// Gets a net by ID. - /// - /// Net ID. - /// The net, or null if not found. - public Net GetNet(int netId) - { - return _nets.FirstOrDefault(n => n.NetId == netId); - } - - /// - /// Gets a net by name. - /// - /// Net name (e.g., "VIN", "GND"). - /// The net, or null if not found. - public Net GetNetByName(string netName) - { - return _nets.FirstOrDefault(n => n.NetName == netName); - } - - /// - /// Checks if a position is occupied by a component. - /// - /// Position to check. - /// True if position is occupied. - public bool IsPositionOccupied(GridPosition pos) - { - return _componentsByPosition.ContainsKey(pos); - } - - /// - /// Gets a component at a specific board position. - /// - /// Position to query. - /// The component at the position, or null if unoccupied. - public PlacedComponent GetComponentAt(GridPosition position) - { - _componentsByPosition.TryGetValue(position, out var component); - return component; - } - - /// - /// Returns a string representation of the board state. - /// - public override string ToString() - { - return $"Board[{Bounds}] {_components.Count} components, {_nets.Count} nets"; - } - } -} diff --git a/Assets/10_Scripts/10_Runtime/10_Core/CircuitUnitFormatter.cs b/Assets/10_Scripts/10_Runtime/10_Core/CircuitUnitFormatter.cs new file mode 100644 index 0000000..5846ba3 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/CircuitUnitFormatter.cs @@ -0,0 +1,142 @@ +namespace CircuitCraft.Utils +{ + /// + /// Formats electrical quantities into compact human-readable strings. + /// + public static class CircuitUnitFormatter + { + private const float One = 1f; + private const float Mega = 1_000_000f; + private const float Kilo = 1_000f; + private const float Milli = 0.001f; + private const float Micro = 0.000_001f; + private const float Nano = 0.000_000_001f; + private const float Giga = 1_000_000_000f; + private const float Pico = 1_000_000_000_000f; + + private const double OneDouble = 1d; + private const double ZeroDouble = 0d; + private const double KiloDouble = 1e3; + private const double MilliDouble = 1e-3; + private const double MicroDouble = 1e-6; + private const double NanoDouble = 1e-9; + + /// + /// Formats resistance with ohm prefixes. + /// + /// Resistance value in ohms. + /// Formatted resistance string such as "4.7kΩ". + public static string FormatResistance(float ohms) + { + if (ohms >= Mega) + { + return $"{ohms / Mega:0.##}MΩ"; + } + + if (ohms >= Kilo) + { + return $"{ohms / Kilo:0.##}kΩ"; + } + + return $"{ohms:0.##}Ω"; + } + + /// + /// Formats capacitance with SI prefixes. + /// + /// Capacitance value in farads. + /// Formatted capacitance string such as "100nF". + public static string FormatCapacitance(float farads) + { + if (farads >= One) + { + return $"{farads:0.##}F"; + } + + if (farads >= Milli) + { + return $"{farads * Kilo:0.##}mF"; + } + + if (farads >= Micro) + { + return $"{farads * Mega:0.##}µF"; + } + + if (farads >= Nano) + { + return $"{farads * Giga:0.##}nF"; + } + + return $"{farads * Pico:0.##}pF"; + } + + /// + /// Formats inductance with SI prefixes. + /// + /// Inductance value in henrys. + /// Formatted inductance string such as "10mH". + public static string FormatInductance(float henrys) + { + if (henrys >= One) + { + return $"{henrys:0.##}H"; + } + + if (henrys >= Milli) + { + return $"{henrys * Kilo:0.##}mH"; + } + + if (henrys >= Micro) + { + return $"{henrys * Mega:0.##}µH"; + } + + return $"{henrys * Giga:0.##}nH"; + } + + /// + /// Formats voltage using fixed precision. + /// + /// Voltage value in volts. + /// Formatted voltage string with a volt unit suffix. + public static string FormatVoltage(double value) + { + return value >= 0 + ? $"{value:0.000} V" + : $"{value:0.000} V"; + } + + /// + /// Formats current with engineering prefixes. + /// + /// Current value in amps. + /// Formatted current string with an ampere unit suffix. + public static string FormatCurrent(double value) + { + var absValue = value >= ZeroDouble ? value : -value; + if (absValue >= KiloDouble) + { + return $"{value / KiloDouble:0.###} kA"; + } + + if (absValue >= OneDouble) + { + return $"{value:0.###} A"; + } + + if (absValue >= MilliDouble) + { + return $"{value * KiloDouble:0.###} mA"; + } + + if (absValue >= MicroDouble) + { + return $"{value / MicroDouble:0.###} µA"; + } + + return $"{value / NanoDouble:0.###} nA"; + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/10_Core/CircuitUnitFormatter.cs.meta b/Assets/10_Scripts/10_Runtime/10_Core/CircuitUnitFormatter.cs.meta new file mode 100644 index 0000000..7134aef --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/10_Core/CircuitUnitFormatter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ad72154e236db144d922b564150573f5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SpiceSharp/SimulationRunner.cs b/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SpiceSharp/SimulationRunner.cs deleted file mode 100644 index 9c03bd4..0000000 --- a/Assets/10_Scripts/10_Runtime/10_Core/Simulation/SpiceSharp/SimulationRunner.cs +++ /dev/null @@ -1,436 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using Cysharp.Threading.Tasks; -using SpiceSharp; -using SpiceSharp.Components; -using SpiceSharp.Simulations; - -namespace CircuitCraft.Simulation.SpiceSharp -{ - /// - /// Runs SpiceSharp simulations and collects probe data. - /// Infrastructure component - handles SpiceSharp simulation execution. - /// - public class SimulationRunner - { - private readonly NetlistBuilder _netlistBuilder; - - /// - /// Creates a new simulation runner with a netlist builder. - /// - public SimulationRunner(NetlistBuilder netlistBuilder = null) - { - _netlistBuilder = netlistBuilder ?? new NetlistBuilder(); - } - - /// - /// Runs a DC operating point simulation. - /// - /// The SpiceSharp circuit to simulate. - /// The domain netlist (for probe definitions). - /// Optional cancellation token. - /// Simulation result with probe measurements. - public async UniTask RunDCOperatingPointAsync( - Circuit circuit, - CircuitNetlist netlist, - CancellationToken cancellationToken = default) - { - return await UniTask.RunOnThreadPool(() => - { - cancellationToken.ThrowIfCancellationRequested(); - - var stopwatch = Stopwatch.StartNew(); - var result = SimulationResult.Success(SimulationType.DCOperatingPoint, 0); - - try - { - var op = new OP("op"); - var exports = CreateExports(op, netlist); - - op.ExportSimulationData += (sender, args) => - { - cancellationToken.ThrowIfCancellationRequested(); - CollectDCResults(exports, netlist.Probes, result); - }; - - cancellationToken.ThrowIfCancellationRequested(); - op.Run(circuit); - - stopwatch.Stop(); - result.ElapsedMilliseconds = stopwatch.Elapsed.TotalMilliseconds; - result.StatusMessage = $"DC operating point completed in {result.ElapsedMilliseconds:F1}ms"; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - stopwatch.Stop(); - result = SimulationResult.Failure(SimulationType.DCOperatingPoint, - SimulationStatus.Error, $"Simulation error: {ex.Message}"); - result.ElapsedMilliseconds = stopwatch.Elapsed.TotalMilliseconds; - result.Issues.Add(SimulationIssue.ConvergenceFailure(ex.Message)); - } - - return result; - }, cancellationToken: cancellationToken); - } - - /// - /// Runs a transient simulation. - /// - /// The SpiceSharp circuit to simulate. - /// The domain netlist (for probe definitions). - /// Transient simulation configuration. - /// Optional cancellation token. - /// Simulation result with probe measurements over time. - public async UniTask RunTransientAsync( - Circuit circuit, - CircuitNetlist netlist, - TransientConfig config, - CancellationToken cancellationToken = default) - { - return await UniTask.RunOnThreadPool(() => - { - cancellationToken.ThrowIfCancellationRequested(); - - var stopwatch = Stopwatch.StartNew(); - var result = SimulationResult.Success(SimulationType.Transient, 0); - - try - { - var step = config.MaxStep > 0 ? config.MaxStep : config.StopTime / 100; - var tran = new Transient("tran", step, config.StopTime); - var exports = CreateExports(tran, netlist); - - // Create temporary storage for time series data - var probeData = new Dictionary>(); - var timePoints = new List(); - - foreach (var probe in netlist.Probes) - { - probeData[probe.Id] = new List(); - } - - tran.ExportSimulationData += (sender, args) => - { - cancellationToken.ThrowIfCancellationRequested(); - timePoints.Add(args.Time); - CollectTransientPoint(exports, netlist.Probes, probeData); - }; - - cancellationToken.ThrowIfCancellationRequested(); - tran.Run(circuit); - cancellationToken.ThrowIfCancellationRequested(); - - // Build final results - BuildTransientResults(netlist.Probes, probeData, timePoints, result); - - stopwatch.Stop(); - result.ElapsedMilliseconds = stopwatch.Elapsed.TotalMilliseconds; - result.StatusMessage = $"Transient completed in {result.ElapsedMilliseconds:F1}ms ({timePoints.Count} points)"; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - stopwatch.Stop(); - result = SimulationResult.Failure(SimulationType.Transient, - SimulationStatus.Error, $"Simulation error: {ex.Message}"); - result.ElapsedMilliseconds = stopwatch.Elapsed.TotalMilliseconds; - result.Issues.Add(SimulationIssue.ConvergenceFailure(ex.Message)); - } - - return result; - }, cancellationToken: cancellationToken); - } - - /// - /// Runs a DC sweep simulation. - /// - /// The SpiceSharp circuit to simulate. - /// The domain netlist (for probe definitions). - /// DC sweep configuration. - /// Optional cancellation token. - /// Simulation result with probe measurements at each sweep point. - public async UniTask RunDCSweepAsync( - Circuit circuit, - CircuitNetlist netlist, - DCSweepConfig config, - CancellationToken cancellationToken = default) - { - return await UniTask.RunOnThreadPool(() => - { - cancellationToken.ThrowIfCancellationRequested(); - - var stopwatch = Stopwatch.StartNew(); - var result = SimulationResult.Success(SimulationType.DCSweep, 0); - - try - { - var dc = new DC("dc", config.SourceId, config.StartValue, config.StopValue, config.StepValue); - var exports = CreateExports(dc, netlist); - - // Create temporary storage for sweep data - var probeData = new Dictionary>(); - var sweepPoints = new List(); - - foreach (var probe in netlist.Probes) - { - probeData[probe.Id] = new List(); - } - - dc.ExportSimulationData += (sender, args) => - { - cancellationToken.ThrowIfCancellationRequested(); - sweepPoints.Add(dc.GetCurrentSweepValue().ToArray()[0]); - CollectTransientPoint(exports, netlist.Probes, probeData); - }; - - cancellationToken.ThrowIfCancellationRequested(); - dc.Run(circuit); - cancellationToken.ThrowIfCancellationRequested(); - - // Build final results - foreach (var probe in netlist.Probes) - { - if (!probeData.TryGetValue(probe.Id, out var data)) continue; - - result.ProbeResults.Add(new ProbeResult - { - ProbeId = probe.Id, - Type = probe.Type, - Target = probe.Target, - MinValue = data.Count > 0 ? data.Min() : 0, - MaxValue = data.Count > 0 ? data.Max() : 0, - AverageValue = data.Count > 0 ? data.Average() : 0, - Value = data.Count > 0 ? data[data.Count - 1] : 0, - TimePoints = new List(sweepPoints), - Values = new List(data) - }); - } - - stopwatch.Stop(); - result.ElapsedMilliseconds = stopwatch.Elapsed.TotalMilliseconds; - result.StatusMessage = $"DC sweep completed in {result.ElapsedMilliseconds:F1}ms ({sweepPoints.Count} points)"; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - stopwatch.Stop(); - result = SimulationResult.Failure(SimulationType.DCSweep, - SimulationStatus.Error, $"Simulation error: {ex.Message}"); - result.ElapsedMilliseconds = stopwatch.Elapsed.TotalMilliseconds; - result.Issues.Add(SimulationIssue.ConvergenceFailure(ex.Message)); - } - - return result; - }, cancellationToken: cancellationToken); - } - - /// - /// Creates SpiceSharp export objects for each probe. - /// - private Dictionary> CreateExports(ISimulation simulation, CircuitNetlist netlist) - { - var exports = new Dictionary>(); - var biasingSimulation = simulation as IBiasingSimulation; - var eventfulSimulation = simulation as IEventfulSimulation; - - if (biasingSimulation == null) - { - throw new InvalidOperationException("Simulation does not support biasing exports."); - } - - foreach (var probe in netlist.Probes) - { - IExport export = null; - - switch (probe.Type) - { - case ProbeType.Voltage: - var refNode = probe.ReferenceNode ?? "0"; - if (refNode == "0" || refNode == netlist.GroundNode) - { - export = new RealVoltageExport(biasingSimulation, probe.Target); - } - else - { - export = new RealVoltageExport(biasingSimulation, probe.Target, refNode); - } - break; - - case ProbeType.Current: - export = new RealCurrentExport(biasingSimulation, probe.Target); - break; - - case ProbeType.Power: - // Power requires separate voltage and current exports - // For now, use the property export if available - if (eventfulSimulation == null) - { - throw new InvalidOperationException("Simulation does not support property exports."); - } - export = new RealPropertyExport(eventfulSimulation, probe.Target, "p"); - break; - } - - if (export != null) - { - exports[probe.Id] = export; - } - } - - return exports; - } - - /// - /// Collects DC operating point results from exports. - /// - private void CollectDCResults(Dictionary> exports, - List probes, SimulationResult result) - { - foreach (var probe in probes) - { - if (exports.TryGetValue(probe.Id, out var export)) - { - try - { - var value = export.Value; - var probeResult = new ProbeResult(probe.Id, probe.Type, probe.Target, value); - result.ProbeResults.Add(probeResult); - } - catch (Exception) - { - // Export may fail for some probes (e.g., power on non-supported elements) - result.Issues.Add(new SimulationIssue(IssueSeverity.Warning, IssueCategory.General, - $"Could not read probe '{probe.Id}' - export failed")); - } - } - } - } - - /// - /// Collects a single transient data point. - /// - private void CollectTransientPoint(Dictionary> exports, - List probes, Dictionary> probeData) - { - foreach (var probe in probes) - { - if (exports.TryGetValue(probe.Id, out var export)) - { - try - { - probeData[probe.Id].Add(export.Value); - } - catch - { - probeData[probe.Id].Add(double.NaN); - } - } - } - } - - /// - /// Builds final transient results with statistics. - /// - private void BuildTransientResults(List probes, - Dictionary> probeData, List timePoints, SimulationResult result) - { - foreach (var probe in probes) - { - if (!probeData.TryGetValue(probe.Id, out var values) || values.Count == 0) - continue; - - var probeResult = new ProbeResult - { - ProbeId = probe.Id, - Type = probe.Type, - Target = probe.Target, - TimePoints = new List(timePoints), - Values = values - }; - - // Calculate statistics - double min = double.MaxValue; - double max = double.MinValue; - double sum = 0; - int validCount = 0; - - foreach (var v in values) - { - if (!double.IsNaN(v) && !double.IsInfinity(v)) - { - if (v < min) min = v; - if (v > max) max = v; - sum += v; - validCount++; - } - } - - if (validCount > 0) - { - probeResult.MinValue = min; - probeResult.MaxValue = max; - probeResult.AverageValue = sum / validCount; - probeResult.Value = values[values.Count - 1]; // Last value - } - - result.ProbeResults.Add(probeResult); - } - } - - /// - /// Checks for overcurrent and overpower conditions. - /// - /// The circuit netlist with element limits. - /// The simulation result to check and add issues to. - public void CheckSafetyLimits(CircuitNetlist netlist, SimulationResult result) - { - foreach (var element in netlist.Elements) - { - // Check overcurrent - if (element.MaxCurrentAmps.HasValue) - { - var current = result.GetCurrent(element.Id); - if (current.HasValue && Math.Abs(current.Value) > element.MaxCurrentAmps.Value) - { - result.Issues.Add(SimulationIssue.Overcurrent( - element.Id, Math.Abs(current.Value), element.MaxCurrentAmps.Value)); - } - } - - // Check overpower for resistors - if (element.Type == ElementType.Resistor && element.MaxPowerWatts.HasValue) - { - var current = result.GetCurrent(element.Id); - if (current.HasValue) - { - var power = current.Value * current.Value * element.Value; // P = I²R - if (power > element.MaxPowerWatts.Value) - { - result.Issues.Add(SimulationIssue.Overpower( - element.Id, power, element.MaxPowerWatts.Value)); - } - } - } - } - - // Update status if we found errors - if (result.HasErrors && result.Status == SimulationStatus.Success) - { - result.Status = SimulationStatus.CompletedWithWarnings; - } - } - } -} diff --git a/Assets/10_Scripts/10_Runtime/10_Views/GridRenderer.cs b/Assets/10_Scripts/10_Runtime/10_Views/GridRenderer.cs deleted file mode 100644 index 014b11f..0000000 --- a/Assets/10_Scripts/10_Runtime/10_Views/GridRenderer.cs +++ /dev/null @@ -1,161 +0,0 @@ -using UnityEngine; -using CircuitCraft.Data; - -namespace CircuitCraft.Views -{ - /// - /// Renders a visual grid for component placement using LineRenderer components. - /// Creates a grid of lines at runtime to visualize the placement area. - /// - public class GridRenderer : MonoBehaviour - { - [Header("Grid Configuration")] - [SerializeField] private GridSettings _gridSettings; - - [Header("Visual Settings")] - [SerializeField] private Color _gridColor = new Color(0.12f, 0.12f, 0.22f, 0.7f); - [SerializeField] private float _lineWidth = 0.02f; - [SerializeField] private Material _lineMaterial; - - private GameObject _gridContainer; - - private void Start() - { - if (_gridSettings == null) - { - Debug.LogError("GridRenderer: GridSettings reference is missing!"); - return; - } - - GenerateGrid(); - } - - /// - /// Generates the grid visualization using LineRenderer components. - /// Creates horizontal and vertical lines as child GameObjects. - /// - private void GenerateGrid() - { - if (_gridSettings == null) - return; - - // Create parent container for all grid lines - _gridContainer = new GameObject("Grid Lines"); - _gridContainer.transform.SetParent(transform); - _gridContainer.transform.localPosition = Vector3.zero; - - // Generate horizontal lines (width + 1 lines) - for (int y = 0; y <= _gridSettings.BoardHeight; y++) - { - CreateHorizontalLine(y); - } - - // Generate vertical lines (height + 1 lines) - for (int x = 0; x <= _gridSettings.BoardWidth; x++) - { - CreateVerticalLine(x); - } - - Debug.Log($"GridRenderer: Generated {_gridSettings.BoardWidth}x{_gridSettings.BoardHeight} grid at {_gridSettings.GridOrigin}"); - } - - /// - /// Creates a horizontal grid line at the specified Y coordinate. - /// - /// Grid Y coordinate (0 to _gridHeight) - private void CreateHorizontalLine(int y) - { - if (_gridSettings == null) - return; - - GameObject lineObj = new GameObject($"HLine_{y}"); - lineObj.transform.SetParent(_gridContainer.transform); - lineObj.transform.localPosition = Vector3.zero; - - LineRenderer line = lineObj.AddComponent(); - ConfigureLineRenderer(line); - - // Set line start and end positions - Vector3 startPos = _gridSettings.GridOrigin + new Vector3(0, 0, y * _gridSettings.CellSize); - Vector3 endPos = _gridSettings.GridOrigin + new Vector3(_gridSettings.BoardWidth * _gridSettings.CellSize, 0, y * _gridSettings.CellSize); - - line.positionCount = 2; - line.SetPosition(0, startPos); - line.SetPosition(1, endPos); - } - - /// - /// Creates a vertical grid line at the specified X coordinate. - /// - /// Grid X coordinate (0 to _gridWidth) - private void CreateVerticalLine(int x) - { - if (_gridSettings == null) - return; - - GameObject lineObj = new GameObject($"VLine_{x}"); - lineObj.transform.SetParent(_gridContainer.transform); - lineObj.transform.localPosition = Vector3.zero; - - LineRenderer line = lineObj.AddComponent(); - ConfigureLineRenderer(line); - - // Set line start and end positions - Vector3 startPos = _gridSettings.GridOrigin + new Vector3(x * _gridSettings.CellSize, 0, 0); - Vector3 endPos = _gridSettings.GridOrigin + new Vector3(x * _gridSettings.CellSize, 0, _gridSettings.BoardHeight * _gridSettings.CellSize); - - line.positionCount = 2; - line.SetPosition(0, startPos); - line.SetPosition(1, endPos); - } - - /// - /// Configures a LineRenderer with the grid's visual settings. - /// - /// LineRenderer component to configure - private void ConfigureLineRenderer(LineRenderer line) - { - line.startWidth = _lineWidth; - line.endWidth = _lineWidth; - line.startColor = _gridColor; - line.endColor = _gridColor; - line.material = _lineMaterial != null ? _lineMaterial : CreateDefaultMaterial(); - line.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; - line.receiveShadows = false; - line.useWorldSpace = true; - } - - /// - /// Creates a default unlit material if no material is assigned. - /// - /// New material instance with grid color - private Material CreateDefaultMaterial() - { - Material mat = new Material(Shader.Find("Sprites/Default")); - mat.color = _gridColor; - return mat; - } - - /// - /// Regenerates the grid when settings change in the Inspector. - /// - private void OnValidate() - { - // Regenerate grid in play mode when values change - if (Application.isPlaying && _gridContainer != null) - { - Destroy(_gridContainer); - GenerateGrid(); - } - } - - private void OnDestroy() - { - // Clean up grid container when destroyed - if (_gridContainer != null) - { - Destroy(_gridContainer); - } - } - } -} diff --git a/Assets/10_Scripts/10_Runtime/20_Controllers/PlacementController.cs b/Assets/10_Scripts/10_Runtime/20_Controllers/PlacementController.cs deleted file mode 100644 index f383dac..0000000 --- a/Assets/10_Scripts/10_Runtime/20_Controllers/PlacementController.cs +++ /dev/null @@ -1,383 +0,0 @@ -using UnityEngine; -using CircuitCraft.Data; -using CircuitCraft.Core; -using CircuitCraft.Utils; -using CircuitCraft.Managers; -using CircuitCraft.Components; -using CircuitCraft.Commands; -using System.Collections.Generic; - -namespace CircuitCraft.Controllers -{ - /// - /// Handles component placement on the grid with mouse input. - /// Detects left-click, converts screen position to grid coordinates, validates placement, - /// and instantiates ComponentView prefabs at snapped grid positions. - /// - public class PlacementController : MonoBehaviour - { - [Header("Dependencies")] - [SerializeField] - [Tooltip("Reference to GameManager for accessing BoardState.")] - private GameManager _gameManager; - - [SerializeField] - [Tooltip("Camera used for raycasting (defaults to Camera.main).")] - private Camera _camera; - - [SerializeField] - [Tooltip("Prefab to instantiate when placing components.")] - private GameObject _componentViewPrefab; - - [Header("Grid Settings")] - [SerializeField] - [Tooltip("Grid configuration settings (cell size, origin, dimensions).")] - private GridSettings _gridSettings; - - [Header("Command History")] - [SerializeField] - [Tooltip("Tracks placement commands for undo and redo.")] - private CommandHistory _commandHistory = new CommandHistory(); - - // State - private ComponentDefinition _selectedComponent; - private GameObject _previewInstance; - - // Cached preview component references - private ComponentView _cachedPreviewView; - private SpriteRenderer _cachedPreviewSprite; - - private void Awake() => Init(); - - private void Init() - { - InitializeCamera(); - ValidateDependencies(); - } - - private void InitializeCamera() - { - if (_camera == null) - { - _camera = Camera.main; - if (_camera == null) - { - Debug.LogError("PlacementController: No camera assigned and Camera.main is null!"); - } - } - } - - private void ValidateDependencies() - { - if (_gameManager == null) - { - Debug.LogError("PlacementController: GameManager reference is missing!"); - } - - if (_componentViewPrefab == null) - { - Debug.LogWarning("PlacementController: ComponentView prefab is not assigned!"); - } - - if (_gridSettings == null) - { - Debug.LogError("PlacementController: GridSettings reference is missing!"); - } - } - - private void OnDestroy() - { - // Cleanup preview instance - DestroyPreview(); - } - - private void Update() - { - HandleCancellation(); - UpdatePreview(); - HandlePlacement(); - } - - /// - /// Handles right-click input to cancel component placement. - /// - private void HandleCancellation() - { - // Right-click to cancel selection - if (Input.GetMouseButtonDown(1)) - { - if (_selectedComponent != null) - { - SetSelectedComponent(null); - } - } - } - - /// - /// Updates the preview instance position and visual state. - /// Preview follows cursor and shows valid/invalid placement state. - /// - private void UpdatePreview() - { - if (_selectedComponent == null || _previewInstance == null || _gridSettings == null) - return; - - // Get cursor grid position - Vector2Int gridPos = GridUtility.ScreenToGridPosition( - Input.mousePosition, - _camera, - _gridSettings.CellSize, - _gridSettings.GridOrigin - ); - - // Check if position is valid - bool isValid = IsValidPlacement(gridPos); - - // Update preview world position - Vector3 worldPos = GridUtility.GridToWorldPosition(gridPos, _gridSettings.CellSize, _gridSettings.GridOrigin); - _previewInstance.transform.position = worldPos; - - // Update preview color (use hover color for invalid state) - using cached reference - if (_cachedPreviewView != null) - { - _cachedPreviewView.SetHovered(!isValid); - } - } - - /// - /// Creates a preview instance of the selected component. - /// Preview is semi-transparent and follows cursor. - /// - private void CreatePreview() - { - if (_componentViewPrefab == null || _selectedComponent == null) - return; - - // Instantiate preview at origin (will be positioned in UpdatePreview) - _previewInstance = Instantiate(_componentViewPrefab, Vector3.zero, Quaternion.identity); - - // Cache ComponentView reference - _cachedPreviewView = _previewInstance.GetComponent(); - if (_cachedPreviewView != null) - { - _cachedPreviewView.Initialize(_selectedComponent); - } - - // Cache SpriteRenderer reference and make semi-transparent - _cachedPreviewSprite = _previewInstance.GetComponent(); - if (_cachedPreviewSprite != null) - { - Color c = _cachedPreviewSprite.color; - c.a = 0.5f; // Semi-transparent - _cachedPreviewSprite.color = c; - } - } - - /// - /// Destroys the preview instance if it exists. - /// - private void DestroyPreview() - { - if (_previewInstance != null) - { - Destroy(_previewInstance); - _previewInstance = null; - - // Clear cached references - _cachedPreviewView = null; - _cachedPreviewSprite = null; - } - } - - /// - /// Handles mouse input for component placement. - /// Checks for left mouse button down, validates position, and places component. - /// - private void HandlePlacement() - { - // Only process input if we have a component selected - if (_selectedComponent == null || _gridSettings == null) - return; - - // Check for left mouse button down - if (Input.GetMouseButtonDown(0)) - { - // Convert mouse position to grid coordinates - Vector2Int gridPos = GridUtility.ScreenToGridPosition( - Input.mousePosition, - _camera, - _gridSettings.CellSize, - _gridSettings.GridOrigin - ); - - // Validate and place component - if (IsValidPlacement(gridPos)) - { - PlaceComponent(gridPos); - } - else - { - Debug.Log($"PlacementController: Invalid placement at {gridPos}"); - } - } - } - - /// - /// Checks if a component can be placed at the given grid position. - /// Validates position is within grid bounds and not already occupied. - /// - /// Grid position to validate. - /// True if placement is valid, false otherwise. - private bool IsValidPlacement(Vector2Int gridPos) - { - if (_gridSettings == null) - return false; - - // Check grid bounds - if (!GridUtility.IsValidGridPosition(gridPos, _gridSettings.BoardWidth, _gridSettings.BoardHeight)) - { - return false; - } - - // Check if position is already occupied - if (_gameManager != null && _gameManager.BoardState != null) - { - GridPosition checkPos = new GridPosition(gridPos.x, gridPos.y); - if (_gameManager.BoardState.IsPositionOccupied(checkPos)) - { - return false; - } - } - - return true; - } - - /// - /// Places a component at the specified grid position. - /// Creates a PlacedComponent in BoardState and instantiates the visual ComponentView. - /// - /// Grid position to place component at. - private void PlaceComponent(Vector2Int gridPos) - { - if (_gameManager == null || _gameManager.BoardState == null) - { - Debug.LogError("PlacementController: Cannot place component - GameManager or BoardState is null!"); - return; - } - - // Create pin instances from component definition - List pinInstances = new List(); - if (_selectedComponent.Pins != null) - { - for (int i = 0; i < _selectedComponent.Pins.Length; i++) - { - var pinDef = _selectedComponent.Pins[i]; - - // Convert PinDefinition local position to GridPosition - // Assuming PinDefinition has a LocalPosition field - if not, use default (0,0) - GridPosition pinLocalPos = new GridPosition(0, 0); // TODO: Get from pinDef.LocalPosition when available - - PinInstance pinInstance = new PinInstance( - pinIndex: i, - pinName: pinDef.ToString(), // TODO: Get actual pin name from PinDefinition - localPosition: pinLocalPos - ); - - pinInstances.Add(pinInstance); - } - } - - // Place component in BoardState - GridPosition position = new GridPosition(gridPos.x, gridPos.y); - var placeCommand = new PlaceComponentCommand( - _gameManager.BoardState, - _selectedComponent.Id, - position, - 0, - pinInstances - ); - _commandHistory.ExecuteCommand(placeCommand); - - Debug.Log($"PlacementController: Placed {_selectedComponent.DisplayName} at {position}"); - - // Instantiate ComponentView prefab at world position - if (_componentViewPrefab != null && _gridSettings != null) - { - Vector3 worldPos = GridUtility.GridToWorldPosition(gridPos, _gridSettings.CellSize, _gridSettings.GridOrigin); - GameObject viewObject = Instantiate(_componentViewPrefab, worldPos, Quaternion.identity); - - // Initialize ComponentView with definition - ComponentView componentView = viewObject.GetComponent(); - if (componentView != null) - { - componentView.Initialize(_selectedComponent); - componentView.GridPosition = gridPos; - } - else - { - Debug.LogWarning("PlacementController: ComponentView prefab does not have ComponentView component!"); - } - } - } - - /// - /// Sets the currently selected component definition for placement. - /// Creates/destroys preview instance when selection changes. - /// - /// ComponentDefinition to place, or null to deselect. - public void SetSelectedComponent(ComponentDefinition definition) - { - _selectedComponent = definition; - - // Destroy old preview - DestroyPreview(); - - if (_selectedComponent != null) - { - Debug.Log($"PlacementController: Selected component: {_selectedComponent.DisplayName}"); - - // Create new preview - CreatePreview(); - } - else - { - Debug.Log("PlacementController: Deselected component"); - } - } - - /// - /// Undoes the last executed placement command. - /// - public void UndoLastAction() - { - _commandHistory.Undo(); - } - - /// - /// Redoes the most recently undone placement command. - /// - public void RedoLastAction() - { - _commandHistory.Redo(); - } - - /// - /// Gets whether there is at least one command available to undo. - /// - public bool CanUndo => _commandHistory.CanUndo; - - /// - /// Gets whether there is at least one command available to redo. - /// - public bool CanRedo => _commandHistory.CanRedo; - - /// - /// Gets the currently selected component definition. - /// - /// Currently selected ComponentDefinition, or null if none selected. - public ComponentDefinition GetSelectedComponent() - { - return _selectedComponent; - } - } -} diff --git a/Assets/10_Scripts/10_Runtime/20_Managers/GameManager.cs b/Assets/10_Scripts/10_Runtime/20_Managers/GameManager.cs deleted file mode 100644 index cdcc61f..0000000 --- a/Assets/10_Scripts/10_Runtime/20_Managers/GameManager.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System; -using System.Threading; -using CircuitCraft.Core; -using CircuitCraft.Simulation; -using Cysharp.Threading.Tasks; -using UnityEngine; - -namespace CircuitCraft.Managers -{ - /// - /// Main game controller for CircuitCraft gameplay. - /// Manages BoardState lifecycle and delegates simulation execution to SimulationManager. - /// - public class GameManager : MonoBehaviour - { - [Header("Board Configuration")] - [SerializeField] private int _boardWidth = 20; - [SerializeField] private int _boardHeight = 20; - - [Header("Dependencies")] - [SerializeField] private BoardState _boardState; - [SerializeField] private SimulationManager _simulationManager; - - private void Awake() => Init(); - - private void Init() - { - InitializeServiceRegistry(); - ValidateSimulationManager(); - InitializeBoardState(); - } - - private void InitializeServiceRegistry() - { - ServiceRegistry.Register(this); - } - - private void ValidateSimulationManager() - { - if (_simulationManager == null) - { - Debug.LogError("GameManager: SimulationManager reference is missing. Assign via Inspector."); - } - } - - private void InitializeBoardState() - { - if (_boardState == null) - { - _boardState = new BoardState(_boardWidth, _boardHeight); - Debug.Log($"GameManager: BoardState initialized ({_boardWidth}x{_boardHeight})"); - } - } - - /// - /// Gets the current BoardState instance. - /// - public BoardState BoardState => _boardState; - - /// - /// Whether a simulation is currently in progress. - /// - public bool IsSimulating => _simulationManager != null && _simulationManager.IsSimulating; - - /// - /// The result of the most recent simulation, or null if no simulation has been run. - /// - public SimulationResult LastSimulationResult => - _simulationManager != null ? _simulationManager.LastSimulationResult : null; - - /// - /// Event raised when a simulation completes (success or failure). - /// - public event Action OnSimulationCompleted - { - add - { - if (_simulationManager != null) - { - _simulationManager.OnSimulationCompleted += value; - } - } - remove - { - if (_simulationManager != null) - { - _simulationManager.OnSimulationCompleted -= value; - } - } - } - - /// - /// Runs a DC operating point simulation on the current BoardState. - /// Called by UI "Simulate" button. - /// - public void RunSimulation() - { - if (_simulationManager == null) - { - Debug.LogWarning("GameManager: Cannot run simulation - SimulationManager reference is missing."); - return; - } - - _simulationManager.RunSimulation(_boardState); - } - - /// - /// Runs a DC operating point simulation on the current BoardState asynchronously. - /// - public async UniTask RunSimulationAsync(CancellationToken cancellationToken = default) - { - if (_simulationManager == null) - { - Debug.LogWarning("GameManager: Cannot run simulation - SimulationManager reference is missing."); - return; - } - - await _simulationManager.RunSimulationAsync(_boardState, cancellationToken); - } - } -} diff --git a/Assets/10_Scripts/10_Runtime/20_Managers/ServiceRegistry.cs b/Assets/10_Scripts/10_Runtime/20_Managers/ServiceRegistry.cs deleted file mode 100644 index 5f1c3ac..0000000 --- a/Assets/10_Scripts/10_Runtime/20_Managers/ServiceRegistry.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace CircuitCraft.Managers -{ - public static class ServiceRegistry - { - public static GameManager GameManager { get; private set; } - public static SimulationManager SimulationManager { get; private set; } - - public static void Register(GameManager gameManager) - { - GameManager = gameManager; - } - - public static void Register(SimulationManager simulationManager) - { - SimulationManager = simulationManager; - } - } -} diff --git a/Assets/10_Scripts/10_Runtime/10_Views.meta b/Assets/10_Scripts/10_Runtime/20_Views.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Views.meta rename to Assets/10_Scripts/10_Runtime/20_Views.meta diff --git a/Assets/10_Scripts/10_Runtime/10_Views/BoardView.cs b/Assets/10_Scripts/10_Runtime/20_Views/BoardView.cs similarity index 69% rename from Assets/10_Scripts/10_Runtime/10_Views/BoardView.cs rename to Assets/10_Scripts/10_Runtime/20_Views/BoardView.cs index 64dcc2a..da80479 100644 --- a/Assets/10_Scripts/10_Runtime/10_Views/BoardView.cs +++ b/Assets/10_Scripts/10_Runtime/20_Views/BoardView.cs @@ -16,7 +16,12 @@ namespace CircuitCraft.Views public class BoardView : MonoBehaviour { [Header("Dependencies")] + [Tooltip("GameManager that owns the active BoardState instance.")] [SerializeField] private GameManager _gameManager; + [Tooltip("SimulationManager used to resolve component definitions for spawned views.")] + [SerializeField] private SimulationManager _simulationManager; + [Tooltip("StageManager used to reset and rebind visuals on stage load.")] + [SerializeField] private StageManager _stageManager; [Header("Prefabs")] [SerializeField] @@ -24,14 +29,16 @@ public class BoardView : MonoBehaviour private GameObject _componentViewPrefab; [Header("Grid Configuration")] + [Tooltip("Grid settings asset used to map grid coordinates to world space.")] [SerializeField] private GridSettings _gridSettings; private BoardState _boardState; + private System.Action _onBoardLoadedHandler; /// /// Maps component InstanceId to its visual representation. /// - private readonly Dictionary _componentViews = new Dictionary(); + private readonly Dictionary _componentViews = new(); /// /// Gets the read-only mapping of component instance IDs to their views. @@ -49,10 +56,12 @@ private void Start() { _boardState = _gameManager.BoardState; - if (_boardState != null) + if (_boardState is not null) { SubscribeToBoardEvents(); +#if UNITY_EDITOR Debug.Log("BoardView: Subscribed to BoardState events"); +#endif } else { @@ -63,6 +72,14 @@ private void Start() { Debug.LogError("BoardView: No GameManager assigned!"); } + if (_stageManager != null) + _stageManager.OnStageLoaded += HandleBoardReset; + + if (_gameManager != null) + { + _onBoardLoadedHandler = _ => HandleBoardReset(); + _gameManager.OnBoardLoaded += _onBoardLoadedHandler; + } if (_gridSettings == null) { @@ -73,6 +90,12 @@ private void Start() private void OnDestroy() { UnsubscribeFromBoardEvents(); + + if (_stageManager != null) + _stageManager.OnStageLoaded -= HandleBoardReset; + + if (_gameManager != null) + _gameManager.OnBoardLoaded -= _onBoardLoadedHandler; } /// @@ -81,7 +104,7 @@ private void OnDestroy() /// private void SubscribeToBoardEvents() { - if (_boardState == null) return; + if (_boardState is null) return; _boardState.OnComponentPlaced += HandleComponentPlaced; _boardState.OnComponentRemoved += HandleComponentRemoved; @@ -93,12 +116,57 @@ private void SubscribeToBoardEvents() /// private void UnsubscribeFromBoardEvents() { - if (_boardState == null) return; + if (_boardState is null) return; _boardState.OnComponentPlaced -= HandleComponentPlaced; _boardState.OnComponentRemoved -= HandleComponentRemoved; } + /// + /// Handles board reset (e.g., when a new stage is loaded). + /// Unsubscribes from old BoardState, destroys all existing component views, + /// and re-subscribes to the new BoardState. + /// + private void HandleBoardReset() + { + // Unsubscribe from old BoardState + UnsubscribeFromBoardEvents(); + + // Destroy all existing component views + foreach (var kvp in _componentViews) + { + if (kvp.Value != null) + Destroy(kvp.Value.gameObject); + } + _componentViews.Clear(); + + // Re-subscribe to new BoardState + if (_gameManager != null) + { + _boardState = _gameManager.BoardState; + if (_boardState is not null) + { + SubscribeToBoardEvents(); + SyncExistingComponents(); + } + } + } + + /// + /// Defensive safety net for components that may already be present on BoardState + /// before this view subscribes to placement events. + /// + private void SyncExistingComponents() + { + if (_boardState is null) return; + + foreach (var component in _boardState.Components) + { + if (_componentViews.ContainsKey(component.InstanceId)) continue; + SpawnComponentView(component); + } + } + /// /// Handles a component being placed on the board. /// Instantiates a prefab at the correct grid position. @@ -106,7 +174,7 @@ private void UnsubscribeFromBoardEvents() /// The placed component data from BoardState. private void HandleComponentPlaced(PlacedComponent component) { - if (component == null) + if (component is null) { Debug.LogWarning("BoardView: Received null PlacedComponent in HandleComponentPlaced"); return; @@ -126,10 +194,7 @@ private void HandleComponentPlaced(PlacedComponent component) /// Finds and destroys the corresponding . /// /// The instance ID of the removed component. - private void HandleComponentRemoved(int instanceId) - { - DestroyComponentView(instanceId); - } + private void HandleComponentRemoved(int instanceId) => DestroyComponentView(instanceId); /// /// Instantiates a prefab at the grid position of the placed component. @@ -155,7 +220,7 @@ private void SpawnComponentView(PlacedComponent component) Vector3 worldPos = GridUtility.GridToWorldPosition(gridPos, _gridSettings.CellSize, _gridSettings.GridOrigin); // Instantiate prefab at world position with rotation, parented to BoardView - Quaternion rotation = Quaternion.Euler(0f, component.Rotation, 0f); + Quaternion rotation = Quaternion.Euler(90f, component.Rotation, 0f); GameObject instance = Instantiate(_componentViewPrefab, worldPos, rotation, transform); instance.name = $"Component_{component.InstanceId}_{component.ComponentDefinitionId}"; @@ -164,12 +229,20 @@ private void SpawnComponentView(PlacedComponent component) if (view != null) { view.GridPosition = gridPos; - // Note: Full initialization with ComponentDefinition requires a definition - // lookup service (IComponentDefinitionProvider). PlacementController handles - // Initialize() when placing via UI interaction. BoardView provides automatic - // sync from BoardState events for programmatic placement. + + // Initialize with ComponentDefinition to set sprite, label, and pin dots + if (_simulationManager != null) + { + var definition = _simulationManager.GetComponentDefinition(component.ComponentDefinitionId); + if (definition != null) + { + view.Initialize(definition); + } + } +#if UNITY_EDITOR Debug.Log($"BoardView: Spawned ComponentView for {component.ComponentDefinitionId} " + $"(InstanceId: {component.InstanceId}) at grid ({gridPos.x}, {gridPos.y})"); +#endif } else { @@ -191,7 +264,9 @@ private void DestroyComponentView(int instanceId) if (view != null) { Destroy(view.gameObject); +#if UNITY_EDITOR Debug.Log($"BoardView: Destroyed ComponentView for InstanceId {instanceId}"); +#endif } _componentViews.Remove(instanceId); diff --git a/Assets/10_Scripts/10_Runtime/10_Views/BoardView.cs.meta b/Assets/10_Scripts/10_Runtime/20_Views/BoardView.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Views/BoardView.cs.meta rename to Assets/10_Scripts/10_Runtime/20_Views/BoardView.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/20_Views/CircuitCraft.Views.asmdef b/Assets/10_Scripts/10_Runtime/20_Views/CircuitCraft.Views.asmdef new file mode 100644 index 0000000..e3e936c --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/20_Views/CircuitCraft.Views.asmdef @@ -0,0 +1,14 @@ +{ + "name": "CircuitCraft.Views", + "rootNamespace": "CircuitCraft.Views", + "references": ["CircuitCraft.Core", "CircuitCraft.Components", "CircuitCraft.Data", "CircuitCraft.Managers", "CircuitCraft.Systems", "CircuitCraft.Utils"], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/10_Scripts/10_Runtime/20_Views/CircuitCraft.Views.asmdef.meta b/Assets/10_Scripts/10_Runtime/20_Views/CircuitCraft.Views.asmdef.meta new file mode 100644 index 0000000..abe8638 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/20_Views/CircuitCraft.Views.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 92c5605dcf7e6944a9a07346d86cb16e +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/10_Views/GridCursor.cs b/Assets/10_Scripts/10_Runtime/20_Views/GridCursor.cs similarity index 68% rename from Assets/10_Scripts/10_Runtime/10_Views/GridCursor.cs rename to Assets/10_Scripts/10_Runtime/20_Views/GridCursor.cs index e26ae91..8d5474f 100644 --- a/Assets/10_Scripts/10_Runtime/10_Views/GridCursor.cs +++ b/Assets/10_Scripts/10_Runtime/20_Views/GridCursor.cs @@ -1,3 +1,4 @@ +using System; using UnityEngine; using CircuitCraft.Data; using CircuitCraft.Utils; @@ -11,18 +12,27 @@ namespace CircuitCraft.Views public class GridCursor : MonoBehaviour { [Header("Camera Settings")] + [Tooltip("Camera used to project mouse position onto the board plane.")] [SerializeField] private Camera _camera; [Header("Grid Settings")] + [Tooltip("Grid settings asset used for screen-to-grid and grid-to-world conversion.")] [SerializeField] private GridSettings _gridSettings; [Header("Visual Settings")] + [Tooltip("Sprite renderer for the cursor visual.")] [SerializeField] private SpriteRenderer _cursorSprite; [SerializeField] private Color _validColor = new Color(0f, 1f, 0f, 0.5f); [SerializeField] private Color _invalidColor = new Color(1f, 0f, 0f, 0.5f); - + private Vector2Int _currentGridPosition; + private Vector3 _lastMousePosition; private bool _isOverGrid; + + /// + /// Raised when the snapped grid cursor position changes. + /// + public event Action OnPositionChanged; private void Awake() => Init(); @@ -63,6 +73,9 @@ private void ValidateGridSettings() private void SetupInitialCursorState() { + // Lay cursor sprite flat on XZ plane for top-down camera + transform.rotation = Quaternion.Euler(90f, 0f, 0f); + // Initialize cursor with valid color if (_cursorSprite != null) { @@ -72,6 +85,11 @@ private void SetupInitialCursorState() private void Update() { + if (Input.mousePosition == _lastMousePosition) + { + return; + } + UpdateCursorPosition(); } @@ -80,9 +98,15 @@ private void Update() /// private void UpdateCursorPosition() { + _lastMousePosition = Input.mousePosition; + if (_camera == null || _gridSettings == null) { + bool wasOverGrid = _isOverGrid; + _isOverGrid = false; SetCursorVisible(false); + if (wasOverGrid) + OnPositionChanged?.Invoke(); return; } @@ -94,25 +118,22 @@ private void UpdateCursorPosition() _gridSettings.GridOrigin ); - // Check if position is valid - bool isValid = GridUtility.IsValidGridPosition(gridPos, _gridSettings.BoardWidth, _gridSettings.BoardHeight); - - // Update current position + // Update current position - grid is unbounded, always over grid + bool positionChanged = !_isOverGrid || _currentGridPosition != gridPos; _currentGridPosition = gridPos; - _isOverGrid = isValid; + _isOverGrid = true; - // Update visual position - if (isValid) - { - Vector3 worldPos = GridUtility.GridToWorldPosition(gridPos, _gridSettings.CellSize, _gridSettings.GridOrigin); - transform.position = worldPos; - SetCursorVisible(true); - SetCursorColor(_validColor); - } - else - { - SetCursorVisible(false); - } + // Update world position - always show cursor at any coordinate + Vector3 worldPos = GridUtility.GridToWorldPosition(gridPos, _gridSettings.CellSize, _gridSettings.GridOrigin); + transform.position = worldPos; + SetCursorVisible(true); + + // Color hint: green inside suggested area, red outside + bool insideSuggested = GridUtility.IsInsideSuggestedArea(gridPos, _gridSettings.SuggestedWidth, _gridSettings.SuggestedHeight); + SetCursorColor(insideSuggested ? _validColor : _invalidColor); + + if (positionChanged) + OnPositionChanged?.Invoke(); } /// @@ -140,18 +161,12 @@ private void SetCursorColor(Color color) /// /// Gets the current grid position under the cursor. /// - public Vector2Int GetCurrentGridPosition() - { - return _currentGridPosition; - } + public Vector2Int GetCurrentGridPosition() => _currentGridPosition; /// /// Checks if the cursor is currently over a valid grid position. /// - public bool IsOverValidGrid() - { - return _isOverGrid; - } + public bool IsOverValidGrid() => _isOverGrid; /// /// Sets cursor to invalid state (for preview of blocked placement). diff --git a/Assets/10_Scripts/10_Runtime/10_Views/GridCursor.cs.meta b/Assets/10_Scripts/10_Runtime/20_Views/GridCursor.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Views/GridCursor.cs.meta rename to Assets/10_Scripts/10_Runtime/20_Views/GridCursor.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/20_Views/GridRenderer.cs b/Assets/10_Scripts/10_Runtime/20_Views/GridRenderer.cs new file mode 100644 index 0000000..ba38427 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/20_Views/GridRenderer.cs @@ -0,0 +1,300 @@ +using System.Collections.Generic; +using UnityEngine; +using CircuitCraft.Data; + +namespace CircuitCraft.Views +{ + /// + /// Renders an infinite scrolling grid aligned to the configured board origin and cell size. + /// + public class GridRenderer : MonoBehaviour + { + [Header("Grid Configuration")] + [Tooltip("Grid settings asset that defines origin and cell size.")] + [SerializeField] private GridSettings _gridSettings; + [Tooltip("Orthographic camera used to determine visible grid extents.")] + [SerializeField] private Camera _camera; + + [Header("Visual Settings")] + [SerializeField] private Color _gridColor = new Color(0.15f, 0.25f, 0.4f, 0.8f); + [SerializeField] private float _lineWidth = 0.03f; + [SerializeField] private Material _lineMaterial; + [SerializeField] private Shader _defaultShader; + + private GameObject _gridContainer; + private readonly List _horizontalLines = new(); + private readonly List _verticalLines = new(); + + private Vector3 _cachedCamPos; + private float _cachedOrthoSize; + private float _cachedAspect; + private bool _hasCachedCameraState; + private bool _forceRefresh = true; + private bool _visualsDirty; + private Material _runtimeLineMaterial; + + private void Awake() + { + EnsureCameraReference(); + EnsureGridContainer(); + + if (_gridSettings == null) + { + Debug.LogError("GridRenderer: GridSettings reference is missing!"); + } + } + + private void LateUpdate() + { + if (_gridSettings == null) + { + return; + } + + EnsureCameraReference(); + if (_camera == null || !_camera.orthographic) + { + return; + } + + bool cameraChanged = HasCameraChanged(); + if (!_forceRefresh && !_visualsDirty && !cameraChanged) + { + return; + } + + EnsureGridContainer(); + + if (_forceRefresh || cameraChanged) + { + UpdateCachedCameraState(); + _forceRefresh = false; + + float cellSize = Mathf.Max(0.0001f, _gridSettings.CellSize); + Vector3 origin = _gridSettings.GridOrigin; + Vector3 camPos = _camera.transform.position; + + float halfHeight = _camera.orthographicSize; + float halfWidth = halfHeight * _camera.aspect; + + float worldMinX = camPos.x - halfWidth; + float worldMaxX = camPos.x + halfWidth; + float worldMinZ = camPos.z - halfHeight; + float worldMaxZ = camPos.z + halfHeight; + + int gridMinX = Mathf.FloorToInt((worldMinX - origin.x) / cellSize) - 1; + int gridMaxX = Mathf.CeilToInt((worldMaxX - origin.x) / cellSize) + 1; + int gridMinY = Mathf.FloorToInt((worldMinZ - origin.z) / cellSize) - 1; + int gridMaxY = Mathf.CeilToInt((worldMaxZ - origin.z) / cellSize) + 1; + + UpdateHorizontalLines(gridMinY, gridMaxY, gridMinX, gridMaxX, origin, cellSize); + UpdateVerticalLines(gridMinX, gridMaxX, gridMinY, gridMaxY, origin, cellSize); + } + + if (_visualsDirty) + { + UpdateLineVisuals(); + _visualsDirty = false; + } + } + + private bool HasCameraChanged() + { + if (!_hasCachedCameraState) + { + return true; + } + + Vector3 currentCamPos = _camera.transform.position; + return currentCamPos != _cachedCamPos + || !Mathf.Approximately(_camera.orthographicSize, _cachedOrthoSize) + || !Mathf.Approximately(_camera.aspect, _cachedAspect); + } + + private void UpdateCachedCameraState() + { + _cachedCamPos = _camera.transform.position; + _cachedOrthoSize = _camera.orthographicSize; + _cachedAspect = _camera.aspect; + _hasCachedCameraState = true; + } + + private void EnsureCameraReference() + { + if (_camera == null) + { + _camera = Camera.main; + } + } + + private void EnsureGridContainer() + { + if (_gridContainer != null) + { + return; + } + + _gridContainer = new GameObject("Grid Lines"); + _gridContainer.transform.SetParent(transform, false); + } + + private void UpdateHorizontalLines(int gridMinY, int gridMaxY, int gridMinX, int gridMaxX, Vector3 origin, float cellSize) + { + int neededCount = Mathf.Max(0, gridMaxY - gridMinY + 1); + EnsureLinePoolSize(_horizontalLines, neededCount, "HLine", _gridColor, _lineWidth); + + float startX = origin.x + (gridMinX * cellSize); + float endX = origin.x + (gridMaxX * cellSize); + float y = origin.y; + + int lineIndex = 0; + for (int gridY = gridMinY; gridY <= gridMaxY; gridY++) + { + float z = origin.z + (gridY * cellSize); + LineRenderer line = _horizontalLines[lineIndex++]; + line.enabled = true; + + line.positionCount = 2; + line.SetPosition(0, new Vector3(startX, y, z)); + line.SetPosition(1, new Vector3(endX, y, z)); + } + + DisableUnusedLines(_horizontalLines, neededCount); + } + + private void UpdateVerticalLines(int gridMinX, int gridMaxX, int gridMinY, int gridMaxY, Vector3 origin, float cellSize) + { + int neededCount = Mathf.Max(0, gridMaxX - gridMinX + 1); + EnsureLinePoolSize(_verticalLines, neededCount, "VLine", _gridColor, _lineWidth); + + float startZ = origin.z + (gridMinY * cellSize); + float endZ = origin.z + (gridMaxY * cellSize); + float y = origin.y; + + int lineIndex = 0; + for (int gridX = gridMinX; gridX <= gridMaxX; gridX++) + { + float x = origin.x + (gridX * cellSize); + LineRenderer line = _verticalLines[lineIndex++]; + line.enabled = true; + + line.positionCount = 2; + line.SetPosition(0, new Vector3(x, y, startZ)); + line.SetPosition(1, new Vector3(x, y, endZ)); + } + + DisableUnusedLines(_verticalLines, neededCount); + } + + private void EnsureLinePoolSize(List pool, int requiredCount, string linePrefix, Color lineColor, float lineWidth) + { + while (pool.Count < requiredCount) + { + int nextIndex = pool.Count; + GameObject lineObject = new GameObject($"{linePrefix}_{nextIndex}"); + lineObject.transform.SetParent(_gridContainer.transform, false); + + LineRenderer line = lineObject.AddComponent(); + ConfigureLineRenderer(line, lineColor, lineWidth); + line.enabled = false; + line.positionCount = 0; + pool.Add(line); + } + } + + private void UpdateLineVisuals() + { + for (int i = 0; i < _horizontalLines.Count; i++) + { + SetLineVisual(_horizontalLines[i], _gridColor, _lineWidth); + } + + for (int i = 0; i < _verticalLines.Count; i++) + { + SetLineVisual(_verticalLines[i], _gridColor, _lineWidth); + } + + } + + private void DisableUnusedLines(List pool, int usedCount) + { + for (int i = usedCount; i < pool.Count; i++) + { + pool[i].positionCount = 0; + pool[i].enabled = false; + } + } + + private void SetLineVisual(LineRenderer line, Color color, float width) + { + line.startWidth = width; + line.endWidth = width; + line.startColor = color; + line.endColor = color; + + Material targetMaterial = _lineMaterial != null ? _lineMaterial : GetOrCreateRuntimeMaterial(); + if (line.sharedMaterial != targetMaterial) + { + line.sharedMaterial = targetMaterial; + } + } + + private Material GetOrCreateRuntimeMaterial() + { + if (_runtimeLineMaterial != null) + { + return _runtimeLineMaterial; + } + + _runtimeLineMaterial = CreateDefaultMaterial(); + return _runtimeLineMaterial; + } + + private void ConfigureLineRenderer(LineRenderer line) => ConfigureLineRenderer(line, _gridColor, _lineWidth); + + private void ConfigureLineRenderer(LineRenderer line, Color lineColor, float width) + { + SetLineVisual(line, lineColor, width); + line.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + line.receiveShadows = false; + line.useWorldSpace = true; + } + + private Material CreateDefaultMaterial() + { + var shader = _defaultShader != null ? _defaultShader : (Shader.Find("Sprites/Default") ?? Shader.Find("Universal Render Pipeline/Unlit") ?? Shader.Find("Unlit/Color")); + Material mat = new Material(shader); + mat.color = _gridColor; + return mat; + } + + private void OnValidate() + { + if (!Application.isPlaying) + { + return; + } + + _forceRefresh = true; + _visualsDirty = true; + + if (_runtimeLineMaterial != null) + { + _runtimeLineMaterial.color = _gridColor; + } + } + + private void OnDestroy() + { + if (_gridContainer != null) + { + Destroy(_gridContainer); + } + + if (_runtimeLineMaterial != null) + { + Destroy(_runtimeLineMaterial); + } + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/10_Views/GridRenderer.cs.meta b/Assets/10_Scripts/10_Runtime/20_Views/GridRenderer.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/10_Views/GridRenderer.cs.meta rename to Assets/10_Scripts/10_Runtime/20_Views/GridRenderer.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/20_Views/PowerFlowVisualizer.cs b/Assets/10_Scripts/10_Runtime/20_Views/PowerFlowVisualizer.cs new file mode 100644 index 0000000..3cd1c2f --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/20_Views/PowerFlowVisualizer.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using CircuitCraft.Components; +using CircuitCraft.Core; +using CircuitCraft.Data; +using CircuitCraft.Managers; +using CircuitCraft.Simulation; +using CircuitCraft.Utils; +using UnityEngine; + +namespace CircuitCraft.Views +{ + /// + /// Applies post-simulation visual feedback for power flow, including trace colors, + /// component overlays, and LED glow effects. + /// + public class PowerFlowVisualizer : MonoBehaviour + { + [Header("Dependencies")] + [Tooltip("GameManager that publishes simulation and board lifecycle events.")] + [SerializeField] private GameManager _gameManager; + [Tooltip("StageManager used to clear visuals on stage transitions.")] + [SerializeField] private StageManager _stageManager; + [Tooltip("BoardView used to access active ComponentView instances.")] + [SerializeField] private BoardView _boardView; + [Tooltip("TraceRenderer used to apply voltage and current visual states.")] + [SerializeField] private TraceRenderer _traceRenderer; + + [Header("LED Glow")] + [SerializeField] private float _ledGlowCurrentThresholdAmps = 1e-6f; + [SerializeField] private Color _ledGlowColor = new Color(1f, 0.65f, 0.1f, 1f); + + private const float TraceCurrentThresholdAmps = 1e-6f; + + private BoardState _boardState; + + private void Start() + { + if (_gameManager == null) + { + Debug.LogError("PowerFlowVisualizer: GameManager reference is missing."); + return; + } + + _gameManager.OnSimulationCompleted += HandleSimulationCompleted; + _gameManager.OnBoardLoaded += HandleBoardStateReplaced; + + if (_stageManager != null) + { + _stageManager.OnStageLoaded += HandleBoardStateReplaced; + } + + HandleBoardStateReplaced(); + } + + private void OnDestroy() + { + UnsubscribeFromBoardState(); + + if (_gameManager != null) + { + _gameManager.OnSimulationCompleted -= HandleSimulationCompleted; + _gameManager.OnBoardLoaded -= HandleBoardStateReplaced; + } + + if (_stageManager != null) + { + _stageManager.OnStageLoaded -= HandleBoardStateReplaced; + } + } + + private void HandleBoardStateReplaced() + { + SubscribeToBoardState(_gameManager != null ? _gameManager.BoardState : null); + ClearVisualization(); + } + + private void HandleBoardStateReplaced(string stageId) + { + _ = stageId; + HandleBoardStateReplaced(); + } + + private void HandleSimulationCompleted(SimulationResult result) + { + if (_boardState is null) + { + ClearVisualization(); + return; + } + + if (result is null || !result.IsSuccess) + { + ClearVisualization(); + return; + } + + var nodeVoltages = SimulationDataMapper.ExtractNodeVoltages(result); + ApplyTraceColors(nodeVoltages); + ApplyTraceCurrentFlow(result); + ApplyComponentOverlays(nodeVoltages, result); + } + + private void ApplyTraceColors(Dictionary nodeVoltages) + { + if (_traceRenderer == null) + { + return; + } + + if (nodeVoltages is null || nodeVoltages.Count == 0) + { + _traceRenderer.ResetColors(); + return; + } + + float minVoltage = float.MaxValue; + float maxVoltage = float.MinValue; + foreach (var v in nodeVoltages.Values) + { + float fv = (float)v; + if (fv < minVoltage) minVoltage = fv; + if (fv > maxVoltage) maxVoltage = fv; + } + _traceRenderer.ApplyVoltageColors(nodeVoltages, minVoltage, maxVoltage); + } + + private void ApplyTraceCurrentFlow(SimulationResult result) + { + if (_traceRenderer == null || _boardState is null) + { + return; + } + + var segmentCurrents = SimulationDataMapper.BuildTraceSegmentCurrentMap( + _boardState.Traces, + _boardView != null ? _boardView.ComponentViews : null, + _boardState, + result, + TraceCurrentThresholdAmps); + _traceRenderer.ApplyCurrentFlow(segmentCurrents); + } + + private void ApplyComponentOverlays(Dictionary nodeVoltages, SimulationResult result) + { + if (_boardView == null) + { + return; + } + + var resistorPowerByInstanceId = SimulationDataMapper.GetResistorPowerMap( + _boardView.ComponentViews, + _boardState, + result); + double maxPower = 0d; + foreach (var power in resistorPowerByInstanceId.Values) + { + if (power > maxPower) maxPower = power; + } + + foreach (var pair in _boardView.ComponentViews) + { + ComponentView componentView = pair.Value; + if (componentView == null) + { + continue; + } + + var placedComponent = _boardState.GetComponent(pair.Key); + if (placedComponent is null) + { + componentView.HideSimulationOverlay(); + componentView.ShowLEDGlow(false, _ledGlowColor); + componentView.HideResistorHeatGlow(); + continue; + } + + var definition = componentView.Definition; + if (definition == null) + { + componentView.HideSimulationOverlay(); + componentView.ShowLEDGlow(false, _ledGlowColor); + componentView.HideResistorHeatGlow(); + continue; + } + + var measuredPinVoltages = new List(); + foreach (var pin in placedComponent.Pins) + { + if (!pin.ConnectedNetId.HasValue) + { + continue; + } + + var net = _boardState.GetNet(pin.ConnectedNetId.Value); + if (net is null) + { + continue; + } + + if (nodeVoltages.TryGetValue(net.NetName, out var pinVoltage)) + { + measuredPinVoltages.Add(pinVoltage); + } + } + + if (measuredPinVoltages.Count == 0) + { + componentView.ShowSimulationOverlay("V: n/a\nI: n/a"); + componentView.ShowLEDGlow(false, _ledGlowColor); + componentView.HideResistorHeatGlow(); + continue; + } + + double voltageSum = 0d; + for (int i = 0; i < measuredPinVoltages.Count; i++) + { + voltageSum += measuredPinVoltages[i]; + } + + var averageVoltage = voltageSum / measuredPinVoltages.Count; + var currentProbeValue = SimulationDataMapper.GetComponentCurrent(componentView.Definition, placedComponent.InstanceId, result); + var currentText = currentProbeValue.HasValue + ? $"I: {CircuitUnitFormatter.FormatCurrent(currentProbeValue.Value)}" + : "I: n/a"; + + componentView.ShowSimulationOverlay( + $"V: {CircuitUnitFormatter.FormatVoltage(averageVoltage)}\n{currentText}"); + + var shouldGlow = definition.Kind == ComponentKind.LED + && currentProbeValue.HasValue + && Math.Abs(currentProbeValue.Value) >= _ledGlowCurrentThresholdAmps; + + componentView.ShowLEDGlow(shouldGlow, _ledGlowColor); + + var isResistor = definition.Kind == ComponentKind.Resistor; + if (isResistor + && resistorPowerByInstanceId.TryGetValue(pair.Key, out var power) + && maxPower > 0) + { + componentView.ShowResistorHeatGlow(true, (float)(power / maxPower)); + } + else + { + componentView.HideResistorHeatGlow(); + } + } + } + + private void ClearVisualization() + { + if (_traceRenderer != null) + { + _traceRenderer.StopCurrentFlow(); + _traceRenderer.ResetColors(); + } + + if (_boardView == null) + { + return; + } + + foreach (var view in _boardView.ComponentViews.Values) + { + if (view == null) + { + continue; + } + + view.HideSimulationOverlay(); + view.ShowLEDGlow(false, _ledGlowColor); + view.HideResistorHeatGlow(); + } + } + + private void SubscribeToBoardState(BoardState boardState) + { + if (_boardState == boardState) + { + return; + } + + UnsubscribeFromBoardState(); + _boardState = boardState; + + if (_boardState is null) + { + return; + } + + _boardState.OnComponentPlaced += HandleBoardEdited; + _boardState.OnComponentRemoved += HandleBoardEdited; + _boardState.OnTraceAdded += HandleBoardTraceEdited; + _boardState.OnTraceRemoved += HandleBoardTraceRemoved; + } + + private void UnsubscribeFromBoardState() + { + if (_boardState is null) + { + return; + } + + _boardState.OnComponentPlaced -= HandleBoardEdited; + _boardState.OnComponentRemoved -= HandleBoardEdited; + _boardState.OnTraceAdded -= HandleBoardTraceEdited; + _boardState.OnTraceRemoved -= HandleBoardTraceRemoved; + } + + private void HandleBoardEdited(PlacedComponent _) + { + ClearVisualization(); + } + + private void HandleBoardEdited(int _) + { + ClearVisualization(); + } + + private void HandleBoardTraceEdited(TraceSegment _) + { + ClearVisualization(); + } + + private void HandleBoardTraceRemoved(int _) + { + ClearVisualization(); + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/20_Views/PowerFlowVisualizer.cs.meta b/Assets/10_Scripts/10_Runtime/20_Views/PowerFlowVisualizer.cs.meta new file mode 100644 index 0000000..b1d2190 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/20_Views/PowerFlowVisualizer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 180ec049bceda5d45a81ac0425d3ef8b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/20_Views/SimulationDataMapper.cs b/Assets/10_Scripts/10_Runtime/20_Views/SimulationDataMapper.cs new file mode 100644 index 0000000..a5c8690 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/20_Views/SimulationDataMapper.cs @@ -0,0 +1,269 @@ +using System; +using System.Collections.Generic; +using CircuitCraft.Components; +using CircuitCraft.Core; +using CircuitCraft.Data; +using CircuitCraft.Simulation; +using CircuitCraft.Systems; +using UnityEngine; + +namespace CircuitCraft.Views +{ + /// + /// Provides pure mapping helpers from simulation results to view-friendly data. + /// + public static class SimulationDataMapper + { + /// + /// Extracts voltage probe values into a node-to-voltage dictionary. + /// + /// Simulation result containing probe values. + /// Dictionary of node name to measured voltage. + public static Dictionary ExtractNodeVoltages(SimulationResult result) + { + var nodeVoltages = new Dictionary(StringComparer.Ordinal); + if (result?.ProbeResults == null) + { + return nodeVoltages; + } + + for (int i = 0; i < result.ProbeResults.Count; i++) + { + var probe = result.ProbeResults[i]; + if (probe == null || probe.Type != ProbeType.Voltage) + { + continue; + } + + if (string.IsNullOrWhiteSpace(probe.Target)) + { + continue; + } + + nodeVoltages[probe.Target] = probe.Value; + } + + return nodeVoltages; + } + + /// + /// Resolves current probe value for a component instance. + /// + /// Component definition. + /// Placed component instance ID. + /// Simulation result containing current probes. + /// Current value when available; otherwise null. + public static double? GetComponentCurrent(ComponentDefinition definition, int instanceId, SimulationResult result) + { + if (result is null || definition == null) + { + return null; + } + + if (definition.Kind == ComponentKind.Ground || definition.Kind == ComponentKind.Probe) + { + return null; + } + + try + { + var elementId = $"{BoardToNetlistConverter.GetElementPrefix(definition.Kind)}{instanceId}"; + return result.GetCurrent(elementId); + } + catch (NotSupportedException) + { + return null; + } + } + + /// + /// Converts component-level current into signed contribution by pin index. + /// + /// Component current. + /// Pin index on component. + /// Signed contribution for the pin. + public static float GetPinSignedCurrentContribution(float current, int pinIndex) + { + return pinIndex switch + { + 0 => current, + 1 => -current, + _ => 0f + }; + } + + /// + /// Resolves resistance value from placed-component override or definition fallback. + /// + /// Resistor definition. + /// Placed component instance. + /// Resolved resistance in ohms. + public static float ResolveResistorValue(ComponentDefinition definition, PlacedComponent component) + { + if (component?.CustomValue.HasValue == true) + { + return component.CustomValue.Value; + } + + if (definition == null) + { + return 0f; + } + + if (definition.ResistanceOhms > 0f) + { + return definition.ResistanceOhms; + } + + return 1000f; + } + + /// + /// Builds a trace segment current map keyed by segment ID. + /// + /// Trace segments to evaluate. + /// Placed component views by instance ID. + /// Board state used for pin/net connectivity. + /// Simulation result containing current probes. + /// Minimum absolute current to include. + /// Segment current map for flow visualization. + public static Dictionary BuildTraceSegmentCurrentMap( + IReadOnlyList traces, + IReadOnlyDictionary componentViews, + BoardState boardState, + SimulationResult result, + float currentThreshold) + { + var segmentCurrentMap = new Dictionary(); + if (result is null + || boardState is null + || componentViews == null + || traces is null + || traces.Count == 0) + { + return segmentCurrentMap; + } + + var netCurrentMap = new Dictionary(); + var fallbackNetCurrentMap = new Dictionary(); + var fallbackNetAbsMap = new Dictionary(); + + foreach (var componentPair in componentViews) + { + var instanceId = componentPair.Key; + var componentView = componentPair.Value; + var placedComponent = boardState.GetComponent(instanceId); + var definition = componentView != null ? componentView.Definition : null; + if (placedComponent is null || definition == null) + { + continue; + } + + var componentCurrent = GetComponentCurrent(definition, instanceId, result); + if (!componentCurrent.HasValue) + { + continue; + } + + var current = (float)componentCurrent.Value; + var absCurrent = Mathf.Abs(current); + if (absCurrent < currentThreshold) + { + continue; + } + + foreach (var pin in placedComponent.Pins) + { + if (!pin.ConnectedNetId.HasValue) + { + continue; + } + + int netId = pin.ConnectedNetId.Value; + var signedContribution = GetPinSignedCurrentContribution(current, pin.PinIndex); + if (!Mathf.Approximately(signedContribution, 0f)) + { + netCurrentMap.TryGetValue(netId, out var aggregateCurrent); + netCurrentMap[netId] = aggregateCurrent + signedContribution; + } + + if (!fallbackNetAbsMap.TryGetValue(netId, out var trackedAbs) || absCurrent > trackedAbs) + { + fallbackNetCurrentMap[netId] = current; + fallbackNetAbsMap[netId] = absCurrent; + } + } + } + + foreach (var trace in traces) + { + if (!netCurrentMap.TryGetValue(trace.NetId, out var netCurrent)) + { + if (!fallbackNetCurrentMap.TryGetValue(trace.NetId, out netCurrent)) + { + continue; + } + } + + if (Mathf.Abs(netCurrent) < currentThreshold) + { + continue; + } + + segmentCurrentMap[trace.SegmentId] = netCurrent; + } + + return segmentCurrentMap; + } + + /// + /// Computes resistor power values keyed by component instance ID. + /// + /// Placed component views by instance ID. + /// Board state used to resolve placed components. + /// Simulation result containing current probes. + /// Instance ID to resistor power map. + public static Dictionary GetResistorPowerMap( + IReadOnlyDictionary componentViews, + BoardState boardState, + SimulationResult result) + { + var resistorPowerByInstanceId = new Dictionary(); + + if (componentViews == null || boardState is null || result is null) + { + return resistorPowerByInstanceId; + } + + foreach (var pair in componentViews) + { + int instanceId = pair.Key; + var componentView = pair.Value; + var definition = componentView != null ? componentView.Definition : null; + var placedComponent = boardState.GetComponent(instanceId); + + if (definition == null || placedComponent is null || definition.Kind != ComponentKind.Resistor) + { + continue; + } + + var current = GetComponentCurrent(definition, instanceId, result); + if (!current.HasValue) + { + continue; + } + + float resistance = ResolveResistorValue(definition, placedComponent); + if (resistance <= 0f) + { + continue; + } + + var power = current.Value * current.Value * resistance; + resistorPowerByInstanceId[instanceId] = power; + } + + return resistorPowerByInstanceId; + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/20_Views/SimulationDataMapper.cs.meta b/Assets/10_Scripts/10_Runtime/20_Views/SimulationDataMapper.cs.meta new file mode 100644 index 0000000..b8ab7fb --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/20_Views/SimulationDataMapper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 67520fc2504bca241b5e8284aafa9751 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/20_Views/TraceGeometryBuilder.cs b/Assets/10_Scripts/10_Runtime/20_Views/TraceGeometryBuilder.cs new file mode 100644 index 0000000..2d8ba02 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/20_Views/TraceGeometryBuilder.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using CircuitCraft.Core; +using UnityEngine; + +namespace CircuitCraft.Views +{ + /// + /// Provides pure geometry and color calculations for trace rendering. + /// + public static class TraceGeometryBuilder + { + /// + /// Normalizes a voltage value into a 0..1 range. + /// + /// The voltage value to normalize. + /// The minimum voltage bound. + /// The maximum voltage bound. + /// A clamped normalized value in the range 0..1. + public static float NormalizeVoltage(float voltage, float min, float max) + { + float range = max - min; + if (range <= float.Epsilon) + { + return 0f; + } + + return Mathf.Clamp01((voltage - min) / range); + } + + /// + /// Generates flow-texture pixels using the sawtooth profile used for animated current overlays. + /// + /// Texture width in pixels. + /// Texture height in pixels. + /// Pixel array in row-major order (y * width + x). + public static Color[] GenerateFlowTexturePixels(int width, int height) + { + if (width <= 0) + { + throw new ArgumentOutOfRangeException(nameof(width), "Width must be positive."); + } + + if (height <= 0) + { + throw new ArgumentOutOfRangeException(nameof(height), "Height must be positive."); + } + + var pixels = new Color[width * height]; + float halfHeight = (height - 1f) * 0.5f; + + for (int x = 0; x < width; x++) + { + float cycle = (x / (float)width) * 4f; + float saw = Mathf.Abs((cycle % 2f) - 1f); + float alpha = Mathf.Max(0f, 1f - (saw * 2f)); + + for (int y = 0; y < height; y++) + { + float vertical = halfHeight <= float.Epsilon + ? 1f + : Mathf.Clamp01(1f - (Mathf.Abs(y - halfHeight) / halfHeight)); + + pixels[(y * width) + x] = new Color(1f, 1f, 1f, alpha * vertical); + } + } + + return pixels; + } + + /// + /// Computes per-segment voltage colors from traces, net lookup, and node voltages. + /// + /// Trace segments to evaluate. + /// Net resolver by net id. + /// Node voltage map keyed by net name. + /// Color used for the minimum voltage. + /// Color used for the maximum voltage. + /// Fallback color when voltage data is unavailable. + /// Map of segment id to computed color. + public static Dictionary ComputeVoltageColors( + IReadOnlyList traces, + Func getNet, + Dictionary nodeVoltages, + Color minColor, + Color maxColor, + Color defaultColor) + { + float minVoltage = 0f; + float maxVoltage = 0f; + if (nodeVoltages is not null && nodeVoltages.Count > 0) + { + minVoltage = float.MaxValue; + maxVoltage = float.MinValue; + + foreach (var voltage in nodeVoltages.Values) + { + float value = (float)voltage; + minVoltage = Mathf.Min(minVoltage, value); + maxVoltage = Mathf.Max(maxVoltage, value); + } + } + + return ComputeVoltageColors( + traces, + getNet, + nodeVoltages, + minVoltage, + maxVoltage, + minColor, + maxColor, + defaultColor); + } + + /// + /// Computes per-segment voltage colors from traces, net lookup, and node voltages using explicit normalization bounds. + /// + /// Trace segments to evaluate. + /// Net resolver by net id. + /// Node voltage map keyed by net name. + /// Minimum voltage bound used for normalization. + /// Maximum voltage bound used for normalization. + /// Color used for the minimum voltage. + /// Color used for the maximum voltage. + /// Fallback color when voltage data is unavailable. + /// Map of segment id to computed color. + public static Dictionary ComputeVoltageColors( + IReadOnlyList traces, + Func getNet, + Dictionary nodeVoltages, + float minVoltage, + float maxVoltage, + Color minColor, + Color maxColor, + Color defaultColor) + { + var colorsBySegment = new Dictionary(); + + if (traces is null || getNet is null) + { + return colorsBySegment; + } + + foreach (var trace in traces) + { + if (trace is null) + { + continue; + } + + var color = defaultColor; + if (nodeVoltages is not null && nodeVoltages.Count > 0) + { + var net = getNet(trace.NetId); + if (net is not null + && !string.IsNullOrWhiteSpace(net.NetName) + && nodeVoltages.TryGetValue(net.NetName, out var voltage)) + { + float normalized = NormalizeVoltage((float)voltage, minVoltage, maxVoltage); + color = Color.Lerp(minColor, maxColor, normalized); + } + } + + colorsBySegment[trace.SegmentId] = color; + } + + return colorsBySegment; + } + + /// + /// Calculates the next wrapped flow texture offset for one segment. + /// + /// Current offset in 0..1 range (or any float that will be wrapped). + /// Segment current in amps. + /// Base animation speed. + /// Current-to-speed scale factor. + /// Maximum animation speed cap. + /// Frame delta time. + /// Wrapped offset in 0..1 range. + public static float CalculateFlowOffset( + float currentOffset, + float current, + float baseSpeed, + float speedScale, + float maxSpeed, + float deltaTime) + { + float direction = Mathf.Sign(current); + float speed = Mathf.Min((Mathf.Abs(current) * speedScale) + baseSpeed, maxSpeed); + float offset = currentOffset + (direction * speed * deltaTime); + return Mathf.Repeat(offset, 1f); + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/20_Views/TraceGeometryBuilder.cs.meta b/Assets/10_Scripts/10_Runtime/20_Views/TraceGeometryBuilder.cs.meta new file mode 100644 index 0000000..e6ec051 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/20_Views/TraceGeometryBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e1ea947e191e7c94a95b4b1d1717e7c5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/20_Views/TraceRenderer.cs b/Assets/10_Scripts/10_Runtime/20_Views/TraceRenderer.cs new file mode 100644 index 0000000..2c46fd0 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/20_Views/TraceRenderer.cs @@ -0,0 +1,518 @@ +using System.Collections.Generic; +using CircuitCraft.Core; +using CircuitCraft.Data; +using CircuitCraft.Managers; +using CircuitCraft.Utils; +using UnityEngine; + +namespace CircuitCraft.Views +{ + /// + /// Renders board trace segments using LineRenderer components. + /// + public class TraceRenderer : MonoBehaviour + { + [Header("Dependencies")] + [Tooltip("GameManager that owns the active BoardState.")] + [SerializeField] private GameManager _gameManager; + [Tooltip("StageManager used to rebuild traces when a stage loads.")] + [SerializeField] private StageManager _stageManager; + [Tooltip("Grid settings asset used for grid-to-world conversion.")] + [SerializeField] private GridSettings _gridSettings; + + [Header("Visual Settings")] + [SerializeField] private Color _wireColor = new Color(0.1f, 0.85f, 1f, 1f); + [SerializeField] private float _wireWidth = 0.08f; + [SerializeField] private float _wireY = 0.05f; + [SerializeField] private Shader _lineShader; + + [Header("Voltage Colors")] + [SerializeField] private Color _voltageMinColor = Color.blue; + [SerializeField] private Color _voltageMaxColor = Color.yellow; + + [Header("Current Flow")] + [SerializeField] private float _flowAnimationBaseSpeed = 0.2f; + [SerializeField] private float _flowAnimationSpeedScale = 2f; + [SerializeField] private float _flowAnimationMaxSpeed = 2.2f; + [SerializeField] private float _flowMinVisibleCurrent = 1e-6f; + [SerializeField] private float _flowWidthMultiplier = 0.8f; + [SerializeField] private Color _flowColor = new Color(1f, 1f, 1f, 0.85f); + + private BoardState _boardState; + private System.Action _onBoardLoadedHandler; + private Material _lineMaterial; + private Material _flowLineMaterial; + private Texture2D _flowTexture; + private readonly Dictionary _traceLines = new(); + private readonly Dictionary _flowLines = new(); + private readonly Dictionary _segmentCurrents = new(); + private readonly Dictionary _segmentFlowOffsets = new(); + + private static readonly int MainTexProperty = Shader.PropertyToID("_MainTex"); + private const int FlowTextureWidth = 64; + private const int FlowTextureHeight = 8; + + private void Start() + { + if (_gameManager == null) + { + Debug.LogError("TraceRenderer: GameManager reference is missing!"); + return; + } + + if (_gridSettings == null) + { + Debug.LogError("TraceRenderer: GridSettings reference is missing!"); + return; + } + + _boardState = _gameManager.BoardState; + if (_boardState is null) + { + Debug.LogWarning("TraceRenderer: BoardState is not available."); + return; + } + + var shader = _lineShader != null ? _lineShader : Shader.Find("Sprites/Default"); + _lineMaterial = new Material(shader); + _flowLineMaterial = new Material(shader); + _flowTexture = CreateFlowTexture(); + Subscribe(); + + foreach (var trace in _boardState.Traces) + { + CreateTraceLine(trace); + } + + if (_stageManager != null) + _stageManager.OnStageLoaded += HandleBoardReset; + + _onBoardLoadedHandler = _ => HandleBoardReset(); + _gameManager.OnBoardLoaded += _onBoardLoadedHandler; + } + + private void OnDestroy() + { + StopCurrentFlow(); + Unsubscribe(); + + if (_stageManager != null) + _stageManager.OnStageLoaded -= HandleBoardReset; + + if (_gameManager != null) + _gameManager.OnBoardLoaded -= _onBoardLoadedHandler; + + foreach (var pair in _traceLines) + { + if (pair.Value != null) + { + Destroy(pair.Value.gameObject); + } + } + _traceLines.Clear(); + + foreach (var pair in _flowLines) + { + if (pair.Value != null) + { + if (pair.Value.sharedMaterial != null && pair.Value.sharedMaterial != _flowLineMaterial) + { + Destroy(pair.Value.sharedMaterial); + } + Destroy(pair.Value.gameObject); + } + } + _flowLines.Clear(); + + if (_lineMaterial != null) + { + Destroy(_lineMaterial); + } + + if (_flowLineMaterial != null) + { + Destroy(_flowLineMaterial); + } + + if (_flowTexture != null) + { + Destroy(_flowTexture); + } + } + + private void Update() + { + if (_segmentCurrents.Count == 0) + { + return; + } + + foreach (var pair in _segmentCurrents) + { + int segmentId = pair.Key; + float current = pair.Value; + if (!_flowLines.TryGetValue(segmentId, out var flowLine) || flowLine == null) + { + continue; + } + + float offset = 0f; + if (_segmentFlowOffsets.TryGetValue(segmentId, out var currentOffset)) + { + offset = currentOffset; + } + + offset = TraceGeometryBuilder.CalculateFlowOffset( + offset, + current, + _flowAnimationBaseSpeed, + _flowAnimationSpeedScale, + _flowAnimationMaxSpeed, + Time.deltaTime); + _segmentFlowOffsets[segmentId] = offset; + + var flowMaterial = flowLine.sharedMaterial; + if (flowMaterial != null) + { + flowMaterial.SetTextureOffset(MainTexProperty, new Vector2(offset, 0f)); + } + } + } + + private void Subscribe() + { + _boardState.OnTraceAdded += HandleTraceAdded; + _boardState.OnTraceRemoved += HandleTraceRemoved; + } + + private void Unsubscribe() + { + if (_boardState is null) + return; + + _boardState.OnTraceAdded -= HandleTraceAdded; + _boardState.OnTraceRemoved -= HandleTraceRemoved; + } + + private void HandleBoardReset() + { + Unsubscribe(); + + StopCurrentFlow(); + + foreach (var pair in _traceLines) + { + if (pair.Value != null) + Destroy(pair.Value.gameObject); + } + _traceLines.Clear(); + + foreach (var pair in _flowLines) + { + if (pair.Value != null) + { + if (pair.Value.sharedMaterial != null && pair.Value.sharedMaterial != _flowLineMaterial) + { + Destroy(pair.Value.sharedMaterial); + } + Destroy(pair.Value.gameObject); + } + } + _flowLines.Clear(); + + _segmentCurrents.Clear(); + _segmentFlowOffsets.Clear(); + + if (_gameManager != null) + { + _boardState = _gameManager.BoardState; + if (_boardState is not null) + { + Subscribe(); + foreach (var trace in _boardState.Traces) + CreateTraceLine(trace); + } + } + } + + /// + /// Applies voltage-based colors to each trace using per-LineRenderer color values. + /// + /// Node name -> voltage lookup map. + /// Lowest voltage in the map used for normalization. + /// Highest voltage in the map used for normalization. + public void ApplyVoltageColors(Dictionary nodeVoltages, float minVoltage, float maxVoltage) + { + if (_boardState is null) + { + return; + } + + if (nodeVoltages is null) + { + ResetColors(); + return; + } + + var traceColors = TraceGeometryBuilder.ComputeVoltageColors( + _boardState.Traces, + _boardState.GetNet, + nodeVoltages, + minVoltage, + maxVoltage, + _voltageMinColor, + _voltageMaxColor, + _wireColor); + + foreach (var pair in _traceLines) + { + if (pair.Value == null) + continue; + + var color = traceColors.TryGetValue(pair.Key, out var mappedColor) + ? mappedColor + : _wireColor; + + pair.Value.startColor = color; + pair.Value.endColor = color; + } + } + + /// + /// Applies animated current-flow visualization based on segment currents. + /// + /// Mapping from segment id to current (amps). + public void ApplyCurrentFlow(Dictionary segmentCurrents) + { + StopCurrentFlow(); + + if (segmentCurrents is null || segmentCurrents.Count == 0) + { + return; + } + + foreach (var pair in _traceLines) + { + if (pair.Value == null) + { + continue; + } + + if (!segmentCurrents.TryGetValue(pair.Key, out var current)) + { + continue; + } + + if (Mathf.Abs(current) < _flowMinVisibleCurrent) + { + continue; + } + + CreateFlowLine(pair.Key, pair.Value); + _segmentCurrents[pair.Key] = current; + } + + UpdateFlowLineVisibility(); + } + + /// + /// Stops current-flow animation and hides all flow overlays. + /// + public void StopCurrentFlow() + { + foreach (var pair in _flowLines) + { + if (pair.Value != null) + { + pair.Value.enabled = false; + } + } + + _segmentCurrents.Clear(); + _segmentFlowOffsets.Clear(); + } + + /// + /// Restores all trace colors to their default wire color. + /// + public void ResetColors() + { + foreach (var line in _traceLines.Values) + { + if (line == null) + continue; + + line.startColor = _wireColor; + line.endColor = _wireColor; + } + } + + private void HandleTraceAdded(TraceSegment trace) + { + CreateTraceLine(trace); + } + + private void HandleTraceRemoved(int segmentId) + { + if (_traceLines.TryGetValue(segmentId, out var line)) + { + if (line != null) + { + Destroy(line.gameObject); + } + + _traceLines.Remove(segmentId); + } + + HideFlowLine(segmentId); + } + + private void CreateTraceLine(TraceSegment trace) + { + if (trace is null || _traceLines.ContainsKey(trace.SegmentId)) + return; + + var lineObject = new GameObject($"Trace_{trace.SegmentId}"); + lineObject.transform.SetParent(transform, false); + + var line = lineObject.AddComponent(); + line.useWorldSpace = true; + line.positionCount = 2; + line.startWidth = _wireWidth; + line.endWidth = _wireWidth; + line.startColor = _wireColor; + line.endColor = _wireColor; + line.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + line.receiveShadows = false; + line.material = _lineMaterial; + + line.SetPosition(0, GridToWireWorld(trace.Start)); + line.SetPosition(1, GridToWireWorld(trace.End)); + + _traceLines[trace.SegmentId] = line; + + if (_segmentCurrents.TryGetValue(trace.SegmentId, out var current) + && Mathf.Abs(current) >= _flowMinVisibleCurrent) + { + CreateFlowLine(trace.SegmentId, line); + } + } + + private void CreateFlowLine(int segmentId, LineRenderer baseLine) + { + if (baseLine == null) + { + return; + } + + if (_flowLines.TryGetValue(segmentId, out var existing) && existing != null) + { + existing.enabled = true; + CopyLinePositions(baseLine, existing); + _segmentFlowOffsets[segmentId] = 0f; + return; + } + + var flowLineObject = new GameObject($"TraceFlow_{segmentId}"); + flowLineObject.transform.SetParent(transform, false); + + var flowLine = flowLineObject.AddComponent(); + flowLine.useWorldSpace = true; + flowLine.positionCount = 2; + flowLine.startWidth = _wireWidth * _flowWidthMultiplier; + flowLine.endWidth = _wireWidth * _flowWidthMultiplier; + flowLine.startColor = _flowColor; + flowLine.endColor = _flowColor; + flowLine.textureMode = LineTextureMode.Tile; + flowLine.alignment = LineAlignment.View; + flowLine.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + flowLine.receiveShadows = false; + flowLine.sortingLayerID = baseLine.sortingLayerID; + flowLine.sortingOrder = baseLine.sortingOrder + 1; + flowLine.material = new Material(_flowLineMaterial) + { + mainTexture = _flowTexture + }; + flowLine.enabled = true; + + CopyLinePositions(baseLine, flowLine); + flowLine.textureScale = new Vector2(2f, 1f); + + _flowLines[segmentId] = flowLine; + _segmentFlowOffsets[segmentId] = 0f; + } + + private void HideFlowLine(int segmentId) + { + if (_flowLines.TryGetValue(segmentId, out var flowLine)) + { + if (flowLine != null) + { + if (flowLine.sharedMaterial != null && flowLine.sharedMaterial != _flowLineMaterial) + { + Destroy(flowLine.sharedMaterial); + } + flowLine.enabled = false; + Destroy(flowLine.gameObject); + } + + _segmentCurrents.Remove(segmentId); + _segmentFlowOffsets.Remove(segmentId); + _flowLines.Remove(segmentId); + } + } + + private void UpdateFlowLineVisibility() + { + foreach (var pair in _flowLines) + { + if (pair.Value == null) + { + continue; + } + + pair.Value.enabled = _segmentCurrents.ContainsKey(pair.Key); + } + } + + private static void CopyLinePositions(LineRenderer source, LineRenderer target) + { + if (source == null || target == null) + { + return; + } + + if (source.positionCount >= 2 && target.positionCount >= 2) + { + target.SetPosition(0, source.GetPosition(0)); + target.SetPosition(1, source.GetPosition(1)); + } + } + + private Texture2D CreateFlowTexture() + { + var texture = new Texture2D(FlowTextureWidth, FlowTextureHeight, TextureFormat.RGBA32, false) + { + wrapMode = TextureWrapMode.Repeat, + filterMode = FilterMode.Bilinear + }; + + var pixels = TraceGeometryBuilder.GenerateFlowTexturePixels(FlowTextureWidth, FlowTextureHeight); + texture.SetPixels(pixels); + + texture.Apply(); + return texture; + } + + private Vector3 GridToWireWorld(GridPosition pos) + { + Vector3 world = GridUtility.GridToWorldPosition( + new Vector2Int(pos.X, pos.Y), + _gridSettings.CellSize, + _gridSettings.GridOrigin + ); + world.y += _wireY; + return world; + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/20_Views/TraceRenderer.cs.meta b/Assets/10_Scripts/10_Runtime/20_Views/TraceRenderer.cs.meta new file mode 100644 index 0000000..f9b03a2 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/20_Views/TraceRenderer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 842c81da4d59de64baeec0a4910139fe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/30_Components/ComponentView.cs b/Assets/10_Scripts/10_Runtime/30_Components/ComponentView.cs deleted file mode 100644 index 5225698..0000000 --- a/Assets/10_Scripts/10_Runtime/30_Components/ComponentView.cs +++ /dev/null @@ -1,272 +0,0 @@ -using UnityEngine; -using CircuitCraft.Data; - -#if UNITY_TEXTMESHPRO -using TMPro; -#endif - -namespace CircuitCraft.Components -{ - /// - /// Visual representation of a placed component on the grid. - /// Displays component sprite, label, and hover/selection highlighting. - /// - [RequireComponent(typeof(SpriteRenderer))] - public class ComponentView : MonoBehaviour - { - [Header("Visual Components")] - [SerializeField] - [Tooltip("SpriteRenderer for displaying the component's visual appearance.")] - private SpriteRenderer _spriteRenderer; - - [SerializeField] - [Tooltip("Text label for displaying component ID/value (TextMeshPro or Unity UI Text).")] -#if UNITY_TEXTMESHPRO - private TextMeshPro _labelText; -#else - private TextMesh _labelText; -#endif - - [Header("Highlight Settings")] - [SerializeField] - [Tooltip("Normal color when not hovered or selected.")] - private Color _normalColor = Color.white; - - [SerializeField] - [Tooltip("Highlight color when hovered (subtle).")] - private Color _hoverColor = new Color(1f, 1f, 0.5f, 1f); // Light yellow - - [SerializeField] - [Tooltip("Highlight color when selected (stronger).")] - private Color _selectedColor = new Color(0.5f, 1f, 0.5f, 1f); // Light green - - [Header("Advanced")] - [SerializeField] - [Tooltip("Optional material override for sprite rendering.")] - private Material _spriteMaterial; - - // State - private ComponentDefinition _definition; - private bool _isHovered; - private bool _isSelected; - - /// - /// Component definition associated with this view. - /// - public ComponentDefinition Definition => _definition; - - /// - /// Grid position of this component (set during placement). - /// - public Vector2Int GridPosition { get; set; } - - private void Awake() => Init(); - - private void Init() - { - InitializeSpriteRenderer(); - InitializeLabelText(); - ApplySpriteMaterial(); - } - - private void InitializeSpriteRenderer() - { - // Auto-assign SpriteRenderer if not set in Inspector - if (_spriteRenderer == null) - { - _spriteRenderer = GetComponent(); - } - } - - private void InitializeLabelText() - { - // Auto-assign label text component if not set in Inspector - if (_labelText == null) - { -#if UNITY_TEXTMESHPRO - _labelText = GetComponentInChildren(); -#else - _labelText = GetComponentInChildren(); -#endif - } - } - - private void ApplySpriteMaterial() - { - // Apply custom material if provided - if (_spriteMaterial != null && _spriteRenderer != null) - { - _spriteRenderer.material = _spriteMaterial; - } - } - - /// - /// Initialize the component view with a ComponentDefinition. - /// Sets sprite and label based on definition properties. - /// - /// ComponentDefinition to visualize. - public void Initialize(ComponentDefinition definition) - { - _definition = definition; - - if (_definition == null) - { - Debug.LogWarning("ComponentView.Initialize: Null ComponentDefinition provided.", this); - return; - } - - // Set sprite from definition prefab (if available) - // Note: Prefab field contains the full prefab - we'll assume it has a SpriteRenderer - // For now, we'll log and wait for proper sprite asset integration - if (_spriteRenderer != null) - { - // TODO: Extract sprite from definition.Prefab or add Sprite field to ComponentDefinition - Debug.Log($"ComponentView.Initialize: {_definition.DisplayName} ({_definition.Id})"); - } - - // Set label text - if (_labelText != null) - { - // Display component value based on kind - string label = FormatComponentLabel(_definition); - _labelText.text = label; - } - - // Apply normal color initially - UpdateVisualState(); - } - - /// - /// Set hover state for visual feedback. - /// - /// True if hovered, false otherwise. - public void SetHovered(bool isHovered) - { - _isHovered = isHovered; - UpdateVisualState(); - } - - /// - /// Set selection state for visual feedback. - /// - /// True if selected, false otherwise. - public void SetSelected(bool isSelected) - { - _isSelected = isSelected; - UpdateVisualState(); - } - - /// - /// Update visual state based on hover/selection flags. - /// Priority: Selected > Hovered > Normal - /// - private void UpdateVisualState() - { - if (_spriteRenderer == null) return; - - if (_isSelected) - { - ApplyHighlight(_selectedColor); - } - else if (_isHovered) - { - ApplyHighlight(_hoverColor); - } - else - { - RemoveHighlight(); - } - } - - /// - /// Apply highlight color to sprite renderer. - /// - /// Color to apply. - private void ApplyHighlight(Color highlightColor) - { - _spriteRenderer.color = highlightColor; - } - - /// - /// Remove highlight and restore normal color. - /// - private void RemoveHighlight() - { - _spriteRenderer.color = _normalColor; - } - - /// - /// Format component label based on component kind and values. - /// - /// ComponentDefinition to format. - /// Formatted label string. - private string FormatComponentLabel(ComponentDefinition definition) - { - switch (definition.Kind) - { - case ComponentKind.Resistor: - return FormatResistance(definition.ResistanceOhms); - - case ComponentKind.Capacitor: - return FormatCapacitance(definition.CapacitanceFarads); - - case ComponentKind.Inductor: - return FormatInductance(definition.InductanceHenrys); - - case ComponentKind.VoltageSource: - return $"{definition.VoltageVolts}V"; - - case ComponentKind.CurrentSource: - return $"{definition.CurrentAmps}A"; - - case ComponentKind.Ground: - return "GND"; - - default: - return definition.DisplayName; - } - } - - /// - /// Format resistance value with engineering notation. - /// - private string FormatResistance(float ohms) - { - if (ohms >= 1_000_000) - return $"{ohms / 1_000_000:0.##}MΩ"; - if (ohms >= 1_000) - return $"{ohms / 1_000:0.##}kΩ"; - return $"{ohms:0.##}Ω"; - } - - /// - /// Format capacitance value with engineering notation. - /// - private string FormatCapacitance(float farads) - { - if (farads >= 1) - return $"{farads:0.##}F"; - if (farads >= 0.001) - return $"{farads * 1_000:0.##}mF"; - if (farads >= 0.000_001) - return $"{farads * 1_000_000:0.##}µF"; - if (farads >= 0.000_000_001) - return $"{farads * 1_000_000_000:0.##}nF"; - return $"{farads * 1_000_000_000_000:0.##}pF"; - } - - /// - /// Format inductance value with engineering notation. - /// - private string FormatInductance(float henrys) - { - if (henrys >= 1) - return $"{henrys:0.##}H"; - if (henrys >= 0.001) - return $"{henrys * 1_000:0.##}mH"; - if (henrys >= 0.000_001) - return $"{henrys * 1_000_000:0.##}µH"; - return $"{henrys * 1_000_000_000:0.##}nH"; - } - } -} diff --git a/Assets/10_Scripts/10_Runtime/20_Controllers.meta b/Assets/10_Scripts/10_Runtime/30_Controllers.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/20_Controllers.meta rename to Assets/10_Scripts/10_Runtime/30_Controllers.meta diff --git a/Assets/10_Scripts/10_Runtime/20_Controllers/CameraController.cs b/Assets/10_Scripts/10_Runtime/30_Controllers/CameraController.cs similarity index 55% rename from Assets/10_Scripts/10_Runtime/20_Controllers/CameraController.cs rename to Assets/10_Scripts/10_Runtime/30_Controllers/CameraController.cs index 876ec63..b0e488a 100644 --- a/Assets/10_Scripts/10_Runtime/20_Controllers/CameraController.cs +++ b/Assets/10_Scripts/10_Runtime/30_Controllers/CameraController.cs @@ -11,15 +11,17 @@ namespace CircuitCraft.Controllers public class CameraController : MonoBehaviour { [Header("Pan Settings")] + [Tooltip("Camera pan speed in world units per second.")] [SerializeField] private float _panSpeed = 10f; - [SerializeField] private float _minX = -10f; - [SerializeField] private float _maxX = 30f; - [SerializeField] private float _minY = -10f; - [SerializeField] private float _maxY = 30f; [Header("Zoom Settings")] + [Tooltip("Mouse-wheel zoom speed multiplier.")] [SerializeField] private float _zoomSpeed = 2f; + + [Tooltip("Minimum orthographic camera size.")] [SerializeField] private float _minZoom = 5f; + + [Tooltip("Maximum orthographic camera size.")] [SerializeField] private float _maxZoom = 20f; private Camera _camera; @@ -57,7 +59,8 @@ private void HandlePan() if (Mathf.Abs(horizontal) > 0.01f || Mathf.Abs(vertical) > 0.01f) { - panDelta = new Vector3(horizontal, vertical, 0f) * _panSpeed * Time.deltaTime; + panDelta = new(horizontal, 0f, vertical); + panDelta *= _panSpeed * Time.deltaTime; } // Middle mouse button panning @@ -81,16 +84,13 @@ private void HandlePan() float worldDeltaX = -mouseDelta.x * (_camera.orthographicSize * 2f / Screen.height); float worldDeltaY = -mouseDelta.y * (_camera.orthographicSize * 2f / Screen.height); - panDelta += new Vector3(worldDeltaX, worldDeltaY, 0f); + panDelta += new Vector3(worldDeltaX, 0f, worldDeltaY); } - // Apply panning with bounds clamping + // Apply panning if (panDelta.sqrMagnitude > 0.0001f) { - Vector3 newPosition = transform.position + panDelta; - newPosition.x = Mathf.Clamp(newPosition.x, _minX, _maxX); - newPosition.y = Mathf.Clamp(newPosition.y, _minY, _maxY); - transform.position = newPosition; + transform.position += panDelta; } } @@ -108,5 +108,45 @@ private void HandleZoom() _camera.orthographicSize = Mathf.Clamp(newSize, _minZoom, _maxZoom); } } + + /// + /// Moves the camera to frame the given world-space bounding rectangle. + /// Adjusts position to center on the rect and zoom to fit it in view. + /// + /// Minimum corner of the rect in world space. + /// Maximum corner of the rect in world space. + /// Extra padding in world units around the rect. + public void FrameBounds(Vector3 worldMin, Vector3 worldMax, float padding = 2f) + { + // Center camera on the rect + Vector3 center = (worldMin + worldMax) * 0.5f; + Vector3 pos = transform.position; + pos.x = center.x; + pos.z = center.z; + transform.position = pos; + + // Adjust zoom to fit the rect + float rectWidth = (worldMax.x - worldMin.x) + padding * 2f; + float rectHeight = (worldMax.z - worldMin.z) + padding * 2f; + + // Choose zoom based on aspect ratio + float aspect = _camera.aspect; + float requiredSize = Mathf.Max(rectHeight * 0.5f, rectWidth * 0.5f / aspect); + _camera.orthographicSize = Mathf.Clamp(requiredSize, _minZoom, _maxZoom); + } + + /// + /// Resets the camera to frame the default suggested area. + /// + /// Width in grid cells. + /// Height in grid cells. + /// Size of each cell in world units. + /// World-space origin of the grid. + public void FrameSuggestedArea(int suggestedWidth, int suggestedHeight, float cellSize, Vector3 gridOrigin) + { + Vector3 worldMin = gridOrigin; + Vector3 worldMax = gridOrigin + new Vector3(suggestedWidth * cellSize, 0f, suggestedHeight * cellSize); + FrameBounds(worldMin, worldMax); + } } } diff --git a/Assets/10_Scripts/10_Runtime/20_Controllers/CameraController.cs.meta b/Assets/10_Scripts/10_Runtime/30_Controllers/CameraController.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/20_Controllers/CameraController.cs.meta rename to Assets/10_Scripts/10_Runtime/30_Controllers/CameraController.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/30_Controllers/CircuitCraft.Controllers.asmdef b/Assets/10_Scripts/10_Runtime/30_Controllers/CircuitCraft.Controllers.asmdef new file mode 100644 index 0000000..0a0641a --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/30_Controllers/CircuitCraft.Controllers.asmdef @@ -0,0 +1,14 @@ +{ + "name": "CircuitCraft.Controllers", + "rootNamespace": "CircuitCraft.Controllers", + "references": ["CircuitCraft.Core", "CircuitCraft.Components", "CircuitCraft.Managers", "CircuitCraft.Commands", "CircuitCraft.Data", "CircuitCraft.Utils"], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/10_Scripts/10_Runtime/30_Controllers/CircuitCraft.Controllers.asmdef.meta b/Assets/10_Scripts/10_Runtime/30_Controllers/CircuitCraft.Controllers.asmdef.meta new file mode 100644 index 0000000..cb148ce --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/30_Controllers/CircuitCraft.Controllers.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ffa54e9bc9132b14a83845f2c7402a93 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/20_Controllers/ComponentInteraction.cs b/Assets/10_Scripts/10_Runtime/30_Controllers/ComponentInteraction.cs similarity index 77% rename from Assets/10_Scripts/10_Runtime/20_Controllers/ComponentInteraction.cs rename to Assets/10_Scripts/10_Runtime/30_Controllers/ComponentInteraction.cs index 012c74a..431dcd3 100644 --- a/Assets/10_Scripts/10_Runtime/20_Controllers/ComponentInteraction.cs +++ b/Assets/10_Scripts/10_Runtime/30_Controllers/ComponentInteraction.cs @@ -2,6 +2,7 @@ using CircuitCraft.Core; using CircuitCraft.Components; using CircuitCraft.Managers; +using CircuitCraft.Commands; namespace CircuitCraft.Controllers { @@ -16,6 +17,10 @@ public class ComponentInteraction : MonoBehaviour [SerializeField] [Tooltip("Reference to the GameManager for accessing BoardState.")] private GameManager _gameManager; + + [SerializeField] + [Tooltip("Stage manager used to refresh cached board references after stage loads.")] + private StageManager _stageManager; [SerializeField] [Tooltip("Camera used for raycasting (defaults to Camera.main if not set).")] @@ -29,6 +34,8 @@ public class ComponentInteraction : MonoBehaviour // State private ComponentView _selectedComponent; private BoardState _boardState; + private CommandHistory _commandHistory; + private System.Action _onBoardLoadedHandler; private void Awake() => Init(); @@ -55,11 +62,30 @@ private void Start() if (_gameManager != null) { _boardState = _gameManager.BoardState; + _commandHistory = _gameManager.CommandHistory; } else { Debug.LogError("ComponentInteraction: GameManager reference is missing.", this); } + + if (_stageManager != null) + _stageManager.OnStageLoaded += HandleBoardReset; + + if (_gameManager != null) + { + _onBoardLoadedHandler = _ => HandleBoardReset(); + _gameManager.OnBoardLoaded += _onBoardLoadedHandler; + } + } + + private void OnDestroy() + { + if (_stageManager != null) + _stageManager.OnStageLoaded -= HandleBoardReset; + + if (_gameManager != null) + _gameManager.OnBoardLoaded -= _onBoardLoadedHandler; } private void Update() @@ -145,6 +171,17 @@ public void DeselectAll() _selectedComponent = null; } } + + private void HandleBoardReset() + { + DeselectAll(); + + if (_gameManager != null) + { + _boardState = _gameManager.BoardState; + _commandHistory = _gameManager.CommandHistory; + } + } /// /// Deletes the currently selected component. @@ -152,36 +189,29 @@ public void DeselectAll() /// private void DeleteSelectedComponent() { - if (_selectedComponent == null || _boardState == null) return; + if (_selectedComponent == null || _boardState is null) return; // Find the PlacedComponent in BoardState by matching GridPosition // (ComponentView stores GridPosition, PlacedComponent has InstanceId) Vector2Int gridPos = _selectedComponent.GridPosition; // Search for component at this grid position - var boardPosition = new GridPosition(gridPos.x, gridPos.y); + GridPosition boardPosition = new(gridPos.x, gridPos.y); PlacedComponent placedComponent = _boardState.GetComponentAt(boardPosition); - if (placedComponent != null) + if (placedComponent is not null) { int instanceId = placedComponent.InstanceId; - // Remove from BoardState - bool isRemoved = _boardState.RemoveComponent(instanceId); + // Remove from BoardState via command history (enables undo) + _commandHistory.ExecuteCommand(new RemoveComponentCommand(_boardState, instanceId)); - if (isRemoved) - { - // Destroy the GameObject - GameObject componentObject = _selectedComponent.gameObject; - _selectedComponent = null; // Clear reference before destroying - Destroy(componentObject); - - Debug.Log($"ComponentInteraction: Deleted component {instanceId} at position {gridPos}"); - } - else - { - Debug.LogWarning($"ComponentInteraction: Failed to remove component {instanceId} from BoardState.", this); - } + // BoardView handles GameObject destruction via OnComponentRemoved event + _selectedComponent = null; + +#if UNITY_EDITOR + Debug.Log($"ComponentInteraction: Deleted component {instanceId} at position {gridPos}"); +#endif } else { @@ -199,8 +229,6 @@ private void DeleteSelectedComponent() /// /// Selected ComponentView, or null if none selected. public ComponentView GetSelectedComponent() - { - return _selectedComponent; - } + => _selectedComponent; } } diff --git a/Assets/10_Scripts/10_Runtime/20_Controllers/ComponentInteraction.cs.meta b/Assets/10_Scripts/10_Runtime/30_Controllers/ComponentInteraction.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/20_Controllers/ComponentInteraction.cs.meta rename to Assets/10_Scripts/10_Runtime/30_Controllers/ComponentInteraction.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/30_Controllers/ComponentPreviewManager.cs b/Assets/10_Scripts/10_Runtime/30_Controllers/ComponentPreviewManager.cs new file mode 100644 index 0000000..c71979c --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/30_Controllers/ComponentPreviewManager.cs @@ -0,0 +1,100 @@ +using CircuitCraft.Components; +using CircuitCraft.Data; +using CircuitCraft.Utils; +using UnityEngine; + +namespace CircuitCraft.Controllers +{ + /// + /// Manages the visual preview instance during component placement. + /// Handles creation, positioning, rotation, and destruction of the preview GameObject. + /// + public class ComponentPreviewManager : MonoBehaviour + { + [SerializeField] + [Tooltip("Prefab to instantiate when placing components.")] + private GameObject _componentViewPrefab; + + private GameObject _previewInstance; + private ComponentView _cachedPreviewView; + private SpriteRenderer _cachedPreviewSprite; + + /// Whether a preview instance currently exists. + public bool HasPreview => _previewInstance != null; + + /// + /// Creates the placement preview for the given component definition. + /// + /// Component definition used to initialize the preview. + public void CreatePreview(ComponentDefinition definition) + { + if (_componentViewPrefab == null || definition == null) + return; + + _previewInstance = Instantiate(_componentViewPrefab, Vector3.zero, Quaternion.Euler(90f, 0f, 0f)); + + _cachedPreviewView = _previewInstance.GetComponent(); + if (_cachedPreviewView != null) + { + _cachedPreviewView.Initialize(definition); + } + + _cachedPreviewSprite = _previewInstance.GetComponent(); + if (_cachedPreviewSprite != null) + { + Color c = _cachedPreviewSprite.color; + c.a = 0.5f; + _cachedPreviewSprite.color = c; + } + } + + /// + /// Destroys the current placement preview instance and clears cached references. + /// + public void DestroyPreview() + { + if (_previewInstance != null) + { + Destroy(_previewInstance); + _previewInstance = null; + + _cachedPreviewView = null; + _cachedPreviewSprite = null; + } + } + + /// + /// Moves the preview to a grid position and updates validity visuals. + /// + /// Target grid position for the preview. + /// Whether the current placement is valid. + /// Grid cell size in world units. + /// Grid origin in world space. + public void UpdatePosition(Vector2Int gridPos, bool isValidPlacement, float cellSize, Vector3 gridOrigin) + { + if (_previewInstance == null) + return; + + Vector3 worldPos = GridUtility.GridToWorldPosition(gridPos, cellSize, gridOrigin); + _previewInstance.transform.position = worldPos; + + if (_cachedPreviewView != null) + _cachedPreviewView.SetHovered(!isValidPlacement); + } + + /// + /// Applies Y-axis rotation to the active preview instance. + /// + /// Rotation in degrees. + public void ApplyRotation(int rotation) + { + if (_previewInstance != null) + _previewInstance.transform.rotation = Quaternion.Euler(90f, rotation, 0f); + } + + private void OnDestroy() + { + DestroyPreview(); + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/30_Controllers/ComponentPreviewManager.cs.meta b/Assets/10_Scripts/10_Runtime/30_Controllers/ComponentPreviewManager.cs.meta new file mode 100644 index 0000000..64118d2 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/30_Controllers/ComponentPreviewManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4f1d2a38454bcf34f94e5f14961b8ae3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/30_Controllers/PinDetector.cs b/Assets/10_Scripts/10_Runtime/30_Controllers/PinDetector.cs new file mode 100644 index 0000000..5f33ac1 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/30_Controllers/PinDetector.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using CircuitCraft.Core; + +namespace CircuitCraft.Controllers +{ + /// + /// Pure logic for detecting nearest pins on components by grid proximity. + /// + public static class PinDetector + { + /// + /// Finds the nearest pin on a single component within the maximum Manhattan distance. + /// + /// Component to evaluate. + /// Mouse position in grid coordinates. + /// Nearest pin when found. + /// Maximum allowed Manhattan distance from the mouse position. + /// True when a nearest pin is found within max distance; otherwise false. + public static bool TryGetNearestPin(PlacedComponent component, GridPosition mouseGridPos, out PinReference pinRef, int maxDistance = 1) + { + pinRef = default; + if (component is null) + return false; + + PinReference? bestPin = null; + int bestDistance = int.MaxValue; + + foreach (var pin in component.Pins) + { + GridPosition pinWorld = component.GetPinWorldPosition(pin.PinIndex); + int distance = pinWorld.ManhattanDistance(mouseGridPos); + if (distance < bestDistance) + { + bestDistance = distance; + bestPin = new PinReference(component.InstanceId, pin.PinIndex, pinWorld); + } + } + + if (!bestPin.HasValue) + return false; + + if (bestDistance > maxDistance) + return false; + + pinRef = bestPin.Value; + return true; + } + + /// + /// Finds the nearest pin across all components within the maximum Manhattan distance. + /// + /// Components to evaluate. + /// Mouse position in grid coordinates. + /// Nearest pin when found. + /// Maximum allowed Manhattan distance from the mouse position. + /// True when a nearest pin is found within max distance; otherwise false. + public static bool TryGetNearestPinFromAll(IReadOnlyList components, GridPosition mouseGridPos, out PinReference pinRef, int maxDistance = 1) + { + pinRef = default; + if (components is null || components.Count == 0) + return false; + + PinReference? bestPin = null; + int bestDistance = int.MaxValue; + + foreach (var component in components) + { + if (component is null) + continue; + + foreach (var pin in component.Pins) + { + GridPosition pinWorld = component.GetPinWorldPosition(pin.PinIndex); + int distance = pinWorld.ManhattanDistance(mouseGridPos); + if (distance < bestDistance) + { + bestDistance = distance; + bestPin = new PinReference(component.InstanceId, pin.PinIndex, pinWorld); + } + } + } + + if (!bestPin.HasValue) + return false; + + if (bestDistance > maxDistance) + return false; + + pinRef = bestPin.Value; + return true; + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/30_Controllers/PinDetector.cs.meta b/Assets/10_Scripts/10_Runtime/30_Controllers/PinDetector.cs.meta new file mode 100644 index 0000000..b39d6cc --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/30_Controllers/PinDetector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 036f923dbb4ad4b43b333da513f9ef86 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/30_Controllers/PlacementController.cs b/Assets/10_Scripts/10_Runtime/30_Controllers/PlacementController.cs new file mode 100644 index 0000000..3f639fe --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/30_Controllers/PlacementController.cs @@ -0,0 +1,240 @@ +using CircuitCraft.Commands; +using CircuitCraft.Core; +using CircuitCraft.Data; +using CircuitCraft.Managers; +using CircuitCraft.Utils; +using UnityEngine; +using UnityEngine.UIElements; + +namespace CircuitCraft.Controllers +{ + /// + /// Handles component selection, preview, rotation, and placement on the board grid. + /// + public class PlacementController : MonoBehaviour + { + [Header("Dependencies")] + [SerializeField] + [Tooltip("Reference to GameManager for accessing BoardState.")] + private GameManager _gameManager; + + [SerializeField] + [Tooltip("Camera used for raycasting (defaults to Camera.main).")] + private Camera _camera; + + [SerializeField] + [Tooltip("Manager responsible for placement preview visuals.")] + private ComponentPreviewManager _componentPreviewManager; + + [Header("Grid Settings")] + [SerializeField] + [Tooltip("Grid configuration settings (cell size, origin, dimensions).")] + private GridSettings _gridSettings; + + private CommandHistory _commandHistory; + + [Tooltip("UI documents used to block placement input when pointer is over UI.")] + [SerializeField] private UIDocument[] _uiDocuments; + private ComponentDefinition _selectedComponent; + private int _currentRotation; + private Vector3 _lastMousePosition = Vector3.negativeInfinity; + private float? _customValue; + + private void Awake() => Init(); + + private void Init() + { + InitializeCamera(); + + if (_componentPreviewManager == null) + _componentPreviewManager = GetComponent(); + + ValidateDependencies(); + + if (_gameManager != null) + _commandHistory = _gameManager.CommandHistory; + + } + + private void InitializeCamera() + { + if (_camera != null) + return; + + _camera = Camera.main; + if (_camera == null) + Debug.LogError("PlacementController: No camera assigned and Camera.main is null!"); + } + + private void ValidateDependencies() + { + if (_gameManager == null) + Debug.LogError("PlacementController: GameManager reference is missing!"); + + if (_gridSettings == null) + Debug.LogError("PlacementController: GridSettings reference is missing!"); + } + + private void Update() + { + HandleCancellation(); + HandleRotation(); + + if (Input.mousePosition != _lastMousePosition) + UpdatePreview(); + + HandlePlacement(); + } + + private void HandleCancellation() + { + if (Input.GetMouseButtonDown(1) && _selectedComponent != null) + SetSelectedComponent(null); + } + + private void HandleRotation() + { + if (!Input.GetKeyDown(KeyCode.R) || _selectedComponent == null) + return; + + _currentRotation = (_currentRotation + RotationConstants.Quarter) % RotationConstants.Full; + _lastMousePosition = Vector3.negativeInfinity; + _componentPreviewManager?.ApplyRotation(_currentRotation); + } + + private void UpdatePreview() + { + if (Input.mousePosition == _lastMousePosition) + return; + + if (_selectedComponent == null || _gridSettings == null) + return; + + if (_componentPreviewManager == null || !_componentPreviewManager.HasPreview) + return; + + _lastMousePosition = Input.mousePosition; + + Vector2Int gridPos = GridUtility.ScreenToGridPosition( + Input.mousePosition, + _camera, + _gridSettings.CellSize, + _gridSettings.GridOrigin); + + GridPosition checkPos = new(gridPos.x, gridPos.y); + bool isValid = PlacementValidator.IsValidPlacement( + _gameManager != null ? _gameManager.BoardState : null, + checkPos); + + _componentPreviewManager.UpdatePosition( + gridPos, + isValid, + _gridSettings.CellSize, + _gridSettings.GridOrigin); + } + + private void HandlePlacement() + { + if (UIInputHelper.IsPointerOverUI(_uiDocuments)) + return; + + if (_selectedComponent == null || _gridSettings == null) + return; + + if (!Input.GetMouseButtonDown(0)) + return; + + Vector2Int gridPos = GridUtility.ScreenToGridPosition( + Input.mousePosition, + _camera, + _gridSettings.CellSize, + _gridSettings.GridOrigin); + + GridPosition checkPos = new(gridPos.x, gridPos.y); + if (PlacementValidator.IsValidPlacement(_gameManager?.BoardState, checkPos)) + { + PlaceComponent(gridPos); + return; + } + +#if UNITY_EDITOR + Debug.Log($"PlacementController: Invalid placement at {gridPos}"); +#endif + } + + private void PlaceComponent(Vector2Int gridPos) + { + if (_gameManager == null || _gameManager.BoardState is null) + { + Debug.LogError("PlacementController: Cannot place component - GameManager or BoardState is null!"); + return; + } + + var pinInstances = PinInstanceFactory.CreatePinInstances(_selectedComponent); + GridPosition position = new(gridPos.x, gridPos.y); + var placeCommand = new PlaceComponentCommand( + _gameManager.BoardState, + _selectedComponent.Id, + position, + _currentRotation, + pinInstances, + _customValue); + + _commandHistory.ExecuteCommand(placeCommand); + +#if UNITY_EDITOR + Debug.Log($"PlacementController: Placed {_selectedComponent.DisplayName} at {position}"); +#endif + } + + /// + /// Sets the currently selected component definition for placement. + /// + /// Component definition to place, or null to clear selection. + public void SetSelectedComponent(ComponentDefinition definition) + { + _selectedComponent = definition; + _currentRotation = 0; + _customValue = null; + _lastMousePosition = Vector3.negativeInfinity; + + _componentPreviewManager?.DestroyPreview(); + + if (_selectedComponent == null) + { +#if UNITY_EDITOR + Debug.Log("PlacementController: Deselected component"); +#endif + return; + } + +#if UNITY_EDITOR + Debug.Log($"PlacementController: Selected component: {_selectedComponent.DisplayName}"); +#endif + _componentPreviewManager?.CreatePreview(_selectedComponent); + } + + /// + /// Gets the currently selected component definition. + /// + /// Selected component definition, or null when no component is selected. + public ComponentDefinition GetSelectedComponent() + => _selectedComponent; + + /// + /// Sets an optional custom value used when placing the selected component. + /// + /// Custom numeric value to apply, or null to use the default definition value. + public void SetCustomValue(float? value) + { + _customValue = value; + } + + /// + /// Gets the custom placement value currently configured for new placements. + /// + /// Configured custom value, or null when not set. + public float? GetCustomValue() + => _customValue; + } +} diff --git a/Assets/10_Scripts/10_Runtime/20_Controllers/PlacementController.cs.meta b/Assets/10_Scripts/10_Runtime/30_Controllers/PlacementController.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/20_Controllers/PlacementController.cs.meta rename to Assets/10_Scripts/10_Runtime/30_Controllers/PlacementController.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/30_Controllers/PlacementValidator.cs b/Assets/10_Scripts/10_Runtime/30_Controllers/PlacementValidator.cs new file mode 100644 index 0000000..5be613a --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/30_Controllers/PlacementValidator.cs @@ -0,0 +1,26 @@ +using CircuitCraft.Core; + +namespace CircuitCraft.Controllers +{ + /// + /// Provides pure placement validation helpers for board coordinates. + /// + public static class PlacementValidator + { + /// + /// Determines whether a component can be placed at the specified board position. + /// + /// Current board state to validate against. + /// Candidate grid position for placement. + /// True when the position is available or board state is unavailable; otherwise false. + public static bool IsValidPlacement(BoardState boardState, GridPosition position) + { + if (boardState is null) + { + return true; + } + + return !boardState.IsPositionOccupied(position); + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/30_Controllers/PlacementValidator.cs.meta b/Assets/10_Scripts/10_Runtime/30_Controllers/PlacementValidator.cs.meta new file mode 100644 index 0000000..b7d12f7 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/30_Controllers/PlacementValidator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3863969e46df0de4f892eb7cd2e9a2d7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/30_Controllers/StageCameraFramer.cs b/Assets/10_Scripts/10_Runtime/30_Controllers/StageCameraFramer.cs new file mode 100644 index 0000000..99883b9 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/30_Controllers/StageCameraFramer.cs @@ -0,0 +1,44 @@ +using System; +using UnityEngine; +using CircuitCraft.Data; +using CircuitCraft.Managers; + +namespace CircuitCraft.Controllers +{ + /// + /// Bridges StageManager (CircuitCraft.Managers assembly) with CameraController (Assembly-CSharp). + /// Subscribes to OnStageLoaded and frames the camera on the active board area. + /// + public class StageCameraFramer : MonoBehaviour + { + [Tooltip("Stage manager that raises stage load events.")] + [SerializeField] private StageManager _stageManager; + + [Tooltip("Camera controller to frame the board area.")] + [SerializeField] private CameraController _cameraController; + + [Tooltip("Grid settings used to convert stage area to world-space framing.")] + [SerializeField] private GridSettings _gridSettings; + + private void OnEnable() + { + if (_stageManager != null) + _stageManager.OnStageLoaded += FrameCamera; + } + + private void OnDisable() + { + if (_stageManager != null) + _stageManager.OnStageLoaded -= FrameCamera; + } + + private void FrameCamera() + { + if (_cameraController == null || _gridSettings == null || _stageManager.CurrentStage == null) + return; + + int side = (int)Math.Ceiling(Math.Sqrt(_stageManager.CurrentStage.TargetArea)); + _cameraController.FrameSuggestedArea(side, side, _gridSettings.CellSize, _gridSettings.GridOrigin); + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/30_Controllers/StageCameraFramer.cs.meta b/Assets/10_Scripts/10_Runtime/30_Controllers/StageCameraFramer.cs.meta new file mode 100644 index 0000000..7f3d2c8 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/30_Controllers/StageCameraFramer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bed5abaf0ebc029408f61efbffbc7f59 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/30_Controllers/WirePathCalculator.cs b/Assets/10_Scripts/10_Runtime/30_Controllers/WirePathCalculator.cs new file mode 100644 index 0000000..e5cf7a3 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/30_Controllers/WirePathCalculator.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using CircuitCraft.Core; + +namespace CircuitCraft.Controllers +{ + /// + /// Pure logic for Manhattan wire path building and trace hit detection. + /// + public static class WirePathCalculator + { + /// + /// Builds a Manhattan wire path between two grid points. + /// + /// Path start position. + /// Path end position. + /// Ordered list of one or two contiguous Manhattan segments. + public static List<(GridPosition start, GridPosition end)> BuildManhattanSegments(GridPosition start, GridPosition end) + { + var segments = new List<(GridPosition start, GridPosition end)>(); + + if (start.X == end.X || start.Y == end.Y) + { + segments.Add((start, end)); + return segments; + } + + var corner = new GridPosition(end.X, start.Y); + segments.Add((start, corner)); + segments.Add((corner, end)); + + return segments; + } + + /// + /// Checks whether a grid point lies on a trace segment. + /// + /// Trace segment to test. + /// Grid point to test. + /// True when the point lies on the trace segment; otherwise false. + public static bool IsPointOnTrace(TraceSegment trace, GridPosition point) + { + if (trace.Start.X == trace.End.X) + { + if (point.X != trace.Start.X) + return false; + + int minY = Math.Min(trace.Start.Y, trace.End.Y); + int maxY = Math.Max(trace.Start.Y, trace.End.Y); + return point.Y >= minY && point.Y <= maxY; + } + + if (point.Y != trace.Start.Y) + return false; + + int minX = Math.Min(trace.Start.X, trace.End.X); + int maxX = Math.Max(trace.Start.X, trace.End.X); + return point.X >= minX && point.X <= maxX; + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/30_Controllers/WirePathCalculator.cs.meta b/Assets/10_Scripts/10_Runtime/30_Controllers/WirePathCalculator.cs.meta new file mode 100644 index 0000000..462cf38 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/30_Controllers/WirePathCalculator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 46cfeeeeffa801e4d91c00019814aa7f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/30_Controllers/WirePreviewManager.cs b/Assets/10_Scripts/10_Runtime/30_Controllers/WirePreviewManager.cs new file mode 100644 index 0000000..be8bdc0 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/30_Controllers/WirePreviewManager.cs @@ -0,0 +1,107 @@ +using CircuitCraft.Core; +using CircuitCraft.Utils; +using UnityEngine; + +namespace CircuitCraft.Controllers +{ + /// + /// Manages LineRenderer preview for wire routing visualization. + /// + public class WirePreviewManager : MonoBehaviour + { + [Header("Preview Settings")] + [SerializeField] private Color _previewColor = Color.yellow; + [SerializeField] private float _previewWidth = 0.08f; + [SerializeField] private float _previewY = 0.06f; + [SerializeField] private Shader _previewShader; + + private LineRenderer _previewLine; + + /// + /// Creates the LineRenderer preview object. Call once during initialization. + /// + public void Initialize() + { + if (_previewLine != null) + return; + + var previewObject = new GameObject("WirePreview"); + previewObject.transform.SetParent(transform, false); + + _previewLine = previewObject.AddComponent(); + _previewLine.useWorldSpace = true; + _previewLine.positionCount = 0; + _previewLine.startWidth = _previewWidth; + _previewLine.endWidth = _previewWidth; + _previewLine.startColor = _previewColor; + _previewLine.endColor = _previewColor; + _previewLine.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + _previewLine.receiveShadows = false; + var shader = _previewShader != null ? _previewShader : Shader.Find("Sprites/Default"); + _previewLine.material = new Material(shader); + } + + /// + /// Shows the preview LineRenderer. + /// + public void Show() + { + if (_previewLine != null) + _previewLine.enabled = true; + } + + /// + /// Hides the preview LineRenderer and resets positions. + /// + public void Hide() + { + if (_previewLine != null) + { + _previewLine.positionCount = 0; + _previewLine.enabled = false; + } + } + + /// + /// Updates the preview path from start pin to current mouse grid position. + /// Uses Manhattan routing (horizontal-first corner). + /// + /// Start pin grid position. + /// Current mouse grid position. + /// Grid cell size in world units. + /// Grid origin in world space. + public void UpdatePath(GridPosition startPinPos, GridPosition currentMouseGridPos, float cellSize, Vector3 gridOrigin) + { + if (_previewLine == null) + return; + + if (startPinPos.X == currentMouseGridPos.X || startPinPos.Y == currentMouseGridPos.Y) + { + _previewLine.positionCount = 2; + SetPosition(0, startPinPos, cellSize, gridOrigin); + SetPosition(1, currentMouseGridPos, cellSize, gridOrigin); + } + else + { + var corner = new GridPosition(currentMouseGridPos.X, startPinPos.Y); + _previewLine.positionCount = 3; + SetPosition(0, startPinPos, cellSize, gridOrigin); + SetPosition(1, corner, cellSize, gridOrigin); + SetPosition(2, currentMouseGridPos, cellSize, gridOrigin); + } + } + + private void SetPosition(int index, GridPosition gridPos, float cellSize, Vector3 gridOrigin) + { + Vector3 worldPos = GridUtility.GridToWorldPosition(new(gridPos.X, gridPos.Y), cellSize, gridOrigin); + worldPos.y += _previewY; + _previewLine.SetPosition(index, worldPos); + } + + private void OnDestroy() + { + if (_previewLine != null && _previewLine.material != null) + Destroy(_previewLine.material); + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/30_Controllers/WirePreviewManager.cs.meta b/Assets/10_Scripts/10_Runtime/30_Controllers/WirePreviewManager.cs.meta new file mode 100644 index 0000000..c3dc22e --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/30_Controllers/WirePreviewManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 93c307d692ba30b4c9d0bb8c81a8e92e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/30_Controllers/WireRoutingController.cs b/Assets/10_Scripts/10_Runtime/30_Controllers/WireRoutingController.cs new file mode 100644 index 0000000..463eaa5 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/30_Controllers/WireRoutingController.cs @@ -0,0 +1,437 @@ +using CircuitCraft.Commands; +using CircuitCraft.Components; +using CircuitCraft.Core; +using CircuitCraft.Data; +using CircuitCraft.Managers; +using CircuitCraft.Utils; +using UnityEngine; +using UnityEngine.UIElements; + +namespace CircuitCraft.Controllers +{ + /// + /// Handles mouse-driven wire routing between component pins. + /// + public class WireRoutingController : MonoBehaviour + { + [Header("Dependencies")] + [Tooltip("Game manager that provides board state and command history.")] + [SerializeField] private GameManager _gameManager; + + [Tooltip("Stage manager used to refresh references when stages load.")] + [SerializeField] private StageManager _stageManager; + + [Tooltip("Grid settings used for screen-to-grid conversion.")] + [SerializeField] private GridSettings _gridSettings; + + [Tooltip("Camera used for routing raycasts and cursor grid conversion.")] + [SerializeField] private Camera _mainCamera; + + [Tooltip("Preview manager used to draw temporary routing paths.")] + [SerializeField] private WirePreviewManager _wirePreviewManager; + + private CommandHistory _commandHistory; + [Tooltip("UI documents used to suppress board input when hovering UI.")] + [SerializeField] private UIDocument[] _uiDocuments; + private bool _wiringModeActive; + + [Tooltip("Placement controller used to coordinate mode switching.")] + [SerializeField] private PlacementController _placementController; + private Label _statusLabel; + + [Header("Raycast Settings")] + [SerializeField] private float _raycastDistance = 100f; + + private enum RoutingState + { + Idle, + PinSelected, + Drawing + } + + private RoutingState _state = RoutingState.Idle; + private BoardState _boardState; + private System.Action _onBoardLoadedHandler; + private PinReference _startPin; + private int _selectedTraceSegmentId = -1; + + private const string StatusWiring = "배선 중... (ESC: 취소)"; + private const string StatusWiringMode = "배선 모드 (Ctrl+W: 해제)"; + private const string StatusReady = "Ready"; + + private void Awake() + { + if (_mainCamera == null) + { + _mainCamera = Camera.main; + } + + if (_gameManager != null) + { + _boardState = _gameManager.BoardState; + _commandHistory = _gameManager.CommandHistory; + } + + if (_wirePreviewManager == null) + _wirePreviewManager = GetComponent(); + + if (_uiDocuments is not null) + { + foreach (var doc in _uiDocuments) + { + if (doc == null || doc.rootVisualElement is null) + continue; + + _statusLabel = doc.rootVisualElement.Q [CreateAssetMenu(fileName = "GridSettings", menuName = "CircuitCraft/Settings/Grid Settings")] public class GridSettings : ScriptableObject @@ -19,14 +21,16 @@ public class GridSettings : ScriptableObject [Tooltip("World position of the grid's origin (0,0).")] private Vector3 _gridOrigin = Vector3.zero; - [Header("Grid Dimensions")] + [Header("Suggested Area")] [SerializeField] - [Tooltip("Width of the grid (number of cells).")] - private int _boardWidth = 20; + [FormerlySerializedAs("_boardWidth")] + [Tooltip("Suggested width of the playable area (not a hard limit).")] + private int _suggestedWidth = 20; [SerializeField] - [Tooltip("Height of the grid (number of cells).")] - private int _boardHeight = 20; + [FormerlySerializedAs("_boardHeight")] + [Tooltip("Suggested height of the playable area (not a hard limit).")] + private int _suggestedHeight = 20; /// /// Size of each grid cell in world units. @@ -38,14 +42,28 @@ public class GridSettings : ScriptableObject /// public Vector3 GridOrigin => _gridOrigin; + /// + /// Suggested width of the playable area (number of cells). + /// This is a UI hint only - placement is not restricted to this area. + /// + public int SuggestedWidth => _suggestedWidth; + + /// + /// Suggested height of the playable area (number of cells). + /// This is a UI hint only - placement is not restricted to this area. + /// + public int SuggestedHeight => _suggestedHeight; + /// /// Width of the grid (number of cells). /// - public int BoardWidth => _boardWidth; + [System.Obsolete("Use SuggestedWidth instead")] + public int BoardWidth => _suggestedWidth; /// /// Height of the grid (number of cells). /// - public int BoardHeight => _boardHeight; + [System.Obsolete("Use SuggestedHeight instead")] + public int BoardHeight => _suggestedHeight; } } diff --git a/Assets/10_Scripts/30_Data/GridSettings.cs.meta b/Assets/10_Scripts/10_Runtime/80_Data/GridSettings.cs.meta similarity index 100% rename from Assets/10_Scripts/30_Data/GridSettings.cs.meta rename to Assets/10_Scripts/10_Runtime/80_Data/GridSettings.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/50_Data/PinDefinition.cs b/Assets/10_Scripts/10_Runtime/80_Data/PinDefinition.cs similarity index 79% rename from Assets/10_Scripts/10_Runtime/50_Data/PinDefinition.cs rename to Assets/10_Scripts/10_Runtime/80_Data/PinDefinition.cs index a21d94e..8fd264b 100644 --- a/Assets/10_Scripts/10_Runtime/50_Data/PinDefinition.cs +++ b/Assets/10_Scripts/10_Runtime/80_Data/PinDefinition.cs @@ -26,6 +26,11 @@ public class PinDefinition /// Grid-aligned local position of the pin relative to component origin. public Vector2Int LocalPosition => _localPosition; + /// + /// Initializes a new pin definition. + /// + /// Unique name for this pin within the component. + /// Grid-aligned local position relative to component origin. public PinDefinition(string pinName, Vector2Int localPosition) { _pinName = pinName; diff --git a/Assets/10_Scripts/10_Runtime/50_Data/PinDefinition.cs.meta b/Assets/10_Scripts/10_Runtime/80_Data/PinDefinition.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/50_Data/PinDefinition.cs.meta rename to Assets/10_Scripts/10_Runtime/80_Data/PinDefinition.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/80_Data/PinInstanceFactory.cs b/Assets/10_Scripts/10_Runtime/80_Data/PinInstanceFactory.cs new file mode 100644 index 0000000..43faeb7 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/80_Data/PinInstanceFactory.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using CircuitCraft.Core; +using UnityEngine; + +namespace CircuitCraft.Data +{ + /// + /// Creates PinInstance lists from ComponentDefinition data. + /// Extracted from PlacementController to allow reuse by StageManager for fixed placements. + /// + public static class PinInstanceFactory + { + /// + /// Creates PinInstance list from a ComponentDefinition. + /// Falls back to StandardPinDefinitions when the component has no explicit pin data. + /// + public static List CreatePinInstances(ComponentDefinition definition) + { + List pinInstances = new(); + var pins = definition.Pins; + + // Fallback to standard pin definitions for components without explicit pins. + if (pins is null || pins.Length == 0) + { + pins = GetStandardPins(definition.Kind); + } + + if (pins is not null) + { + for (int i = 0; i < pins.Length; i++) + { + var pinDef = pins[i]; + GridPosition pinLocalPos = new GridPosition(pinDef.LocalPosition.x, pinDef.LocalPosition.y); + PinInstance pinInstance = new PinInstance( + pinIndex: i, + pinName: pinDef.PinName, + localPosition: pinLocalPos + ); + pinInstances.Add(pinInstance); + } + } + + return pinInstances; + } + + private static PinDefinition[] GetStandardPins(ComponentKind kind) + { + return kind switch + { + ComponentKind.BJT => StandardPinDefinitions.BJT, + ComponentKind.MOSFET => StandardPinDefinitions.MOSFET, + ComponentKind.Diode or ComponentKind.LED or ComponentKind.ZenerDiode => StandardPinDefinitions.Diode, + ComponentKind.VoltageSource or ComponentKind.CurrentSource => StandardPinDefinitions.VerticalTwoPin, + _ => StandardPinDefinitions.TwoPin + }; + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/80_Data/PinInstanceFactory.cs.meta b/Assets/10_Scripts/10_Runtime/80_Data/PinInstanceFactory.cs.meta new file mode 100644 index 0000000..b9c5ace --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/80_Data/PinInstanceFactory.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4537d8156281f5240af3f0319911cfa1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/50_Data/StageDefinition.cs b/Assets/10_Scripts/10_Runtime/80_Data/StageDefinition.cs similarity index 56% rename from Assets/10_Scripts/10_Runtime/50_Data/StageDefinition.cs rename to Assets/10_Scripts/10_Runtime/80_Data/StageDefinition.cs index 61edfb4..eb0f07d 100644 --- a/Assets/10_Scripts/10_Runtime/50_Data/StageDefinition.cs +++ b/Assets/10_Scripts/10_Runtime/80_Data/StageDefinition.cs @@ -3,6 +3,33 @@ namespace CircuitCraft.Data { + /// + /// Defines a fixed component placement that is seeded on stage load. + /// + [System.Serializable] + public struct FixedPlacement + { + /// The component to place at stage load. + [Tooltip("The component to place (e.g., VoltageSource_5V, Ground).")] + public ComponentDefinition component; + + /// Grid position where the component is placed. + [Tooltip("Grid position where the component is placed.")] + public Vector2Int position; + + /// Rotation in degrees (0, 90, 180, 270). + [Tooltip("Rotation in degrees (0, 90, 180, 270).")] + public int rotation; + + /// Whether to override the component default electrical value. + [Tooltip("Whether to override the component's default electrical value.")] + public bool overrideCustomValue; + + /// Custom electrical value used when is true. + [Tooltip("Custom electrical value (only used when overrideCustomValue is true).")] + public float customValue; + } + /// /// Defines the data for a playable stage in the game. /// This ScriptableObject stores configuration such as grid size, allowed components, and win conditions. @@ -34,9 +61,15 @@ public class StageDefinition : ScriptableObject [Header("Grid Configuration")] [SerializeField] [Tooltip("The dimensions of the playable grid.")] + [HideInInspector] [FormerlySerializedAs("gridSize")] private Vector2Int _gridSize; + [Header("Footprint Target")] + [SerializeField] + [Tooltip("Target footprint area for scoring. 0 falls back to legacy grid area.")] + private int _targetArea; + [Header("Allowed Components")] [SerializeField] [Tooltip("The set of components the player is allowed to use in this stage.")] @@ -54,11 +87,16 @@ public class StageDefinition : ScriptableObject [FormerlySerializedAs("budgetLimit")] private float _budgetLimit; - [Header("Additional Constraints")] + [Header("Fixed Components")] [SerializeField] - [Tooltip("Extra constraints for this stage.")] - [FormerlySerializedAs("constraints")] - private StageConstraints _constraints; + [Tooltip("Components pre-placed on the board that the player cannot move or remove.")] + private FixedPlacement[] _fixedPlacements; + + [Header("Circuit Diagram")] + [SerializeField] + [TextArea(3, 10)] + [Tooltip("Text description of the example circuit for this stage. Shown in the circuit diagram modal.")] + private string _circuitDiagramDescription; /// Unique internal identifier for the stage. public string StageId => _stageId; @@ -72,8 +110,8 @@ public class StageDefinition : ScriptableObject /// The sequential number of the stage within its world. public int StageNumber => _stageNumber; - /// The dimensions of the playable grid. - public Vector2Int GridSize => _gridSize; + /// Target footprint area used in scoring. + public int TargetArea => _targetArea > 0 ? _targetArea : Mathf.Max(1, _gridSize.x * _gridSize.y); /// The set of components the player is allowed to use in this stage. public ComponentDefinition[] AllowedComponents => _allowedComponents; @@ -84,7 +122,11 @@ public class StageDefinition : ScriptableObject /// Maximum budget allowed for the circuit. 0 means no limit. public float BudgetLimit => _budgetLimit; - /// Extra constraints for this stage. - public StageConstraints Constraints => _constraints; + /// Components pre-placed on the board that the player cannot move or remove. + public FixedPlacement[] FixedPlacements => _fixedPlacements; + + /// Text description of the example circuit for this stage. + public string CircuitDiagramDescription => _circuitDiagramDescription; + } } diff --git a/Assets/10_Scripts/10_Runtime/50_Data/StageDefinition.cs.meta b/Assets/10_Scripts/10_Runtime/80_Data/StageDefinition.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/50_Data/StageDefinition.cs.meta rename to Assets/10_Scripts/10_Runtime/80_Data/StageDefinition.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/50_Data/StageTestCase.cs b/Assets/10_Scripts/10_Runtime/80_Data/StageTestCase.cs similarity index 72% rename from Assets/10_Scripts/10_Runtime/50_Data/StageTestCase.cs rename to Assets/10_Scripts/10_Runtime/80_Data/StageTestCase.cs index 1736af6..16063db 100644 --- a/Assets/10_Scripts/10_Runtime/50_Data/StageTestCase.cs +++ b/Assets/10_Scripts/10_Runtime/80_Data/StageTestCase.cs @@ -20,6 +20,10 @@ public class StageTestCase [FormerlySerializedAs("expectedVoltage")] private float _expectedVoltage; + [SerializeField] + [Tooltip("The net/node name in the circuit netlist to probe (e.g., 'NET1').")] + private string _probeNode; + [SerializeField] [Tooltip("The allowable error margin for the measured voltage.")] [FormerlySerializedAs("tolerance")] @@ -31,6 +35,12 @@ public class StageTestCase /// The expected voltage value at the measured point. public float ExpectedVoltage => _expectedVoltage; + /// The net/node name in the circuit netlist to probe. + public string ProbeNode => _probeNode; + + /// True when this test case has a usable probe node configured. + public bool HasProbeNode => !string.IsNullOrWhiteSpace(_probeNode); + /// The allowable error margin for the measured voltage. public float Tolerance => _tolerance; } diff --git a/Assets/10_Scripts/10_Runtime/50_Data/StageTestCase.cs.meta b/Assets/10_Scripts/10_Runtime/80_Data/StageTestCase.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/50_Data/StageTestCase.cs.meta rename to Assets/10_Scripts/10_Runtime/80_Data/StageTestCase.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/50_Data/StandardPinDefinitions.cs b/Assets/10_Scripts/10_Runtime/80_Data/StandardPinDefinitions.cs similarity index 51% rename from Assets/10_Scripts/10_Runtime/50_Data/StandardPinDefinitions.cs rename to Assets/10_Scripts/10_Runtime/80_Data/StandardPinDefinitions.cs index b552618..d56daa1 100644 --- a/Assets/10_Scripts/10_Runtime/50_Data/StandardPinDefinitions.cs +++ b/Assets/10_Scripts/10_Runtime/80_Data/StandardPinDefinitions.cs @@ -11,21 +11,31 @@ public static class StandardPinDefinitions /// Standard 2-pin definition (e.g., resistor, capacitor, inductor). /// Pin 0: Terminal A, Pin 1: Terminal B /// - public static PinDefinition[] TwoPin => new[] + public static PinDefinition[] TwoPin => new PinDefinition[] { - new PinDefinition("A", new Vector2Int(0, 0)), - new PinDefinition("B", new Vector2Int(1, 0)) + new("A", new(0, 0)), + new("B", new(1, 0)) + }; + + /// + /// Standard vertical 2-pin definition for voltage/current sources. + /// Pin 0: Negative (-) bottom, Pin 1: Positive (+) top + /// + public static PinDefinition[] VerticalTwoPin => new PinDefinition[] + { + new("-", new(0, 0)), + new("+", new(0, 1)) }; /// /// BJT transistor pins (NPN/PNP). /// Pin 0: Collector (C), Pin 1: Base (B), Pin 2: Emitter (E) /// - public static PinDefinition[] BJT => new[] + public static PinDefinition[] BJT => new PinDefinition[] { - new PinDefinition("C", new Vector2Int(0, 1)), - new PinDefinition("B", new Vector2Int(0, 0)), - new PinDefinition("E", new Vector2Int(0, -1)) + new("C", new(0, 1)), + new("B", new(0, 0)), + new("E", new(0, -1)) }; /// @@ -33,21 +43,21 @@ public static class StandardPinDefinitions /// Pin 0: Drain (D), Pin 1: Gate (G), Pin 2: Source (S) /// Note: Bulk is internally connected to Source in discrete MOSFETs. /// - public static PinDefinition[] MOSFET => new[] + public static PinDefinition[] MOSFET => new PinDefinition[] { - new PinDefinition("D", new Vector2Int(1, 1)), - new PinDefinition("G", new Vector2Int(0, 0)), - new PinDefinition("S", new Vector2Int(1, -1)) + new("D", new(1, 1)), + new("G", new(0, 0)), + new("S", new(1, -1)) }; /// /// Diode pins (including LED). /// Pin 0: Anode (+), Pin 1: Cathode (-) /// - public static PinDefinition[] Diode => new[] + public static PinDefinition[] Diode => new PinDefinition[] { - new PinDefinition("Anode", new Vector2Int(0, 0)), - new PinDefinition("Cathode", new Vector2Int(1, 0)) + new("Anode", new(0, 0)), + new("Cathode", new(1, 0)) }; } } diff --git a/Assets/10_Scripts/10_Runtime/50_Data/StandardPinDefinitions.cs.meta b/Assets/10_Scripts/10_Runtime/80_Data/StandardPinDefinitions.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/50_Data/StandardPinDefinitions.cs.meta rename to Assets/10_Scripts/10_Runtime/80_Data/StandardPinDefinitions.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/80_Data/TransistorModel.cs b/Assets/10_Scripts/10_Runtime/80_Data/TransistorModel.cs new file mode 100644 index 0000000..3132e75 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/80_Data/TransistorModel.cs @@ -0,0 +1,200 @@ +namespace CircuitCraft.Data +{ + /// + /// Common BJT transistor models. + /// + public enum BJTModel + { + /// Generic NPN transistor model. + Generic_NPN, + + /// Generic PNP transistor model. + Generic_PNP, + + /// 2N2222 - Popular general-purpose NPN transistor. + _2N2222, + + /// 2N3904 - Small signal NPN transistor. + _2N3904, + + /// 2N3906 - Small signal PNP transistor. + _2N3906, + + /// BC547 - General purpose NPN transistor. + BC547, + + /// BC557 - General purpose PNP transistor. + BC557, + + /// Custom model - use custom parameters. + Custom = 7, + + /// BC548 - General purpose NPN transistor. + BC548 = 8, + + /// BC558 - General purpose PNP transistor. + BC558 = 9, + + /// BC556 - High voltage PNP transistor. + BC556 = 10, + + /// BC337 - Medium power NPN transistor. + BC337 = 11, + + /// TIP31 - NPN power transistor. + TIP31 = 12, + + /// TIP32 - PNP power transistor. + TIP32 = 13, + + /// 2N696 - General purpose NPN transistor. + _2N696 = 14 + } + + /// + /// Common MOSFET transistor models. + /// + public enum MOSFETModel + { + /// Generic N-Channel MOSFET model. + Generic_NMOS, + + /// Generic P-Channel MOSFET model. + Generic_PMOS, + + /// 2N7000 - N-Channel enhancement mode MOSFET. + _2N7000, + + /// BS170 - N-Channel small signal MOSFET. + BS170, + + /// IRF540 - N-Channel power MOSFET. + IRF540, + + /// IRF9540 - P-Channel power MOSFET. + IRF9540, + + /// Custom model - use custom parameters. + Custom = 6, + + /// BS250 - P-Channel small signal MOSFET. + BS250 = 7, + + /// IRF3205 - N-Channel high power MOSFET. + IRF3205 = 8, + + /// IRF530 - N-Channel power MOSFET. + IRF530 = 9, + + /// IRLZ44N - N-Channel logic level MOSFET. + IRLZ44N = 10, + + /// IRF520 - N-Channel power MOSFET. + IRF520 = 11, + + /// BSS84 - P-Channel small signal MOSFET. + BSS84 = 12, + + /// TP0610L - P-Channel small signal MOSFET. + TP0610L = 13, + + /// FQP27P06 - P-Channel power MOSFET. + FQP27P06 = 14 + } + + /// + /// Common diode models. + /// + public enum DiodeModel + { + /// Generic silicon diode. + Generic, + + /// 1N4148 - Fast switching diode. + _1N4148, + + /// 1N4001 - General purpose rectifier diode. + _1N4001, + + /// 1N5819 - Schottky barrier diode. + _1N5819, + + /// Generic red LED. + LED_Red, + + /// Generic green LED. + LED_Green, + + /// Generic blue LED. + LED_Blue, + + /// 1N4728A - 3.3V zener diode. + Zener_3V3, + + /// 1N4733A - 5.1V zener diode. + Zener_5V1, + + /// 1N4736A - 6.8V zener diode. + Zener_6V8, + + /// 1N4739A - 9.1V zener diode. + Zener_9V1, + + /// 1N4742A - 12V zener diode. + Zener_12V, + + /// 1N4744A - 15V zener diode. + Zener_15V, + + /// Custom model - use custom parameters. + Custom = 13, + + /// 1N4007 - High voltage general purpose rectifier. + _1N4007 = 14, + + /// 1N914 - Fast switching signal diode. + _1N914 = 15, + + /// 1N4002 - General purpose rectifier (100V). + _1N4002 = 16, + + /// 1N4004 - General purpose rectifier (400V). + _1N4004 = 17, + + /// 1N5408 - High current rectifier (3A, 1000V). + _1N5408 = 18, + + /// BAS40 - Schottky small signal diode. + BAS40 = 19, + + /// BAT85 - Schottky small signal diode. + BAT85 = 20, + + /// Generic white LED. + LED_White = 21, + + /// Generic yellow LED. + LED_Yellow = 22, + + /// 1N4729A - 3.6V zener diode. + Zener_3V6 = 23, + + /// 1N4730A - 3.9V zener diode. + Zener_3V9 = 24, + + /// 1N4731A - 4.3V zener diode. + Zener_4V3 = 25, + + /// 1N4732A - 4.7V zener diode. + Zener_4V7 = 26, + + /// 1N4734A - 5.6V zener diode. + Zener_5V6 = 27, + + /// 1N4738A - 8.2V zener diode. + Zener_8V2 = 28, + + /// 1N4750A - 27V zener diode. + Zener_27V = 29 + } +} diff --git a/Assets/10_Scripts/10_Runtime/50_Data/TransistorModel.cs.meta b/Assets/10_Scripts/10_Runtime/80_Data/TransistorModel.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/50_Data/TransistorModel.cs.meta rename to Assets/10_Scripts/10_Runtime/80_Data/TransistorModel.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/50_Data/TransistorPolarity.cs b/Assets/10_Scripts/10_Runtime/80_Data/TransistorPolarity.cs similarity index 100% rename from Assets/10_Scripts/10_Runtime/50_Data/TransistorPolarity.cs rename to Assets/10_Scripts/10_Runtime/80_Data/TransistorPolarity.cs diff --git a/Assets/10_Scripts/10_Runtime/50_Data/TransistorPolarity.cs.meta b/Assets/10_Scripts/10_Runtime/80_Data/TransistorPolarity.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/50_Data/TransistorPolarity.cs.meta rename to Assets/10_Scripts/10_Runtime/80_Data/TransistorPolarity.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/60_Commands.meta b/Assets/10_Scripts/10_Runtime/85_Commands.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/60_Commands.meta rename to Assets/10_Scripts/10_Runtime/85_Commands.meta diff --git a/Assets/10_Scripts/10_Runtime/60_Commands/10_Board.meta b/Assets/10_Scripts/10_Runtime/85_Commands/10_Board.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/60_Commands/10_Board.meta rename to Assets/10_Scripts/10_Runtime/85_Commands/10_Board.meta diff --git a/Assets/10_Scripts/10_Runtime/85_Commands/10_Board/PlaceComponentCommand.cs b/Assets/10_Scripts/10_Runtime/85_Commands/10_Board/PlaceComponentCommand.cs new file mode 100644 index 0000000..4c141f2 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/85_Commands/10_Board/PlaceComponentCommand.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Linq; +using CircuitCraft.Core; + +namespace CircuitCraft.Commands +{ + /// + /// Places a component instance on the board and supports undo removal. + /// + public class PlaceComponentCommand : ICommand + { + private readonly BoardState _boardState; + private readonly string _componentDefId; + private readonly GridPosition _position; + private readonly int _rotation; + private readonly List _pins; + private readonly float? _customValue; + private int _placedInstanceId; + + /// + /// Gets a user-facing description of this placement command. + /// + public string Description => $"Place {_componentDefId} at {_position}"; + + /// + /// Creates a command that places a component at the provided board position. + /// + /// The board state to mutate. + /// The component definition identifier. + /// The origin placement position on the grid. + /// The rotation in degrees. + /// The component pin instances used for placement. + /// User-specified custom electrical value (null to use definition default). + public PlaceComponentCommand( + BoardState boardState, + string componentDefId, + GridPosition position, + int rotation, + IEnumerable pins, + float? customValue = null) + { + _boardState = boardState; + _componentDefId = componentDefId; + _position = position; + _rotation = rotation; + _pins = pins?.ToList() ?? new(); + _customValue = customValue; + } + + /// + /// Executes the component placement. + /// + public void Execute() + { + var placed = _boardState.PlaceComponent(_componentDefId, _position, _rotation, _pins, _customValue); + _placedInstanceId = placed.InstanceId; + } + + /// + /// Undoes the component placement by removing the placed instance. + /// + public void Undo() + { + _boardState.RemoveComponent(_placedInstanceId); + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/60_Commands/10_Board/PlaceComponentCommand.cs.meta b/Assets/10_Scripts/10_Runtime/85_Commands/10_Board/PlaceComponentCommand.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/60_Commands/10_Board/PlaceComponentCommand.cs.meta rename to Assets/10_Scripts/10_Runtime/85_Commands/10_Board/PlaceComponentCommand.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/85_Commands/10_Board/RemoveComponentCommand.cs b/Assets/10_Scripts/10_Runtime/85_Commands/10_Board/RemoveComponentCommand.cs new file mode 100644 index 0000000..56642a0 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/85_Commands/10_Board/RemoveComponentCommand.cs @@ -0,0 +1,149 @@ +using System.Collections.Generic; +using System.Linq; +using CircuitCraft.Core; + +namespace CircuitCraft.Commands +{ + /// + /// Removes an existing component and captures topology state for undo restoration. + /// + public class RemoveComponentCommand : ICommand + { + private readonly BoardState _boardState; + private readonly int _instanceId; + + private int _currentInstanceId; + private string _componentDefId; + private GridPosition _position; + private int _rotation; + private List _pins; + private bool _hasCapturedState; + private float? _capturedCustomValue; + private readonly List<(int netId, string netName)> _capturedNets = new(); + private readonly List<(int netId, GridPosition start, GridPosition end)> _capturedTraces = new(); + private readonly List<(int netId, int pinIndex)> _capturedPinConnections = new(); + + /// + /// Gets a user-facing description of this component removal command. + /// + public string Description => $"Remove component {_instanceId}"; + + /// + /// Creates a command that removes the specified component instance. + /// + /// The board state to mutate. + /// The component instance identifier to remove. + public RemoveComponentCommand(BoardState boardState, int instanceId) + { + _boardState = boardState; + _instanceId = instanceId; + _currentInstanceId = instanceId; + } + + /// + /// Executes the component removal and captures connected topology for undo. + /// + public void Execute() + { + var component = _boardState.GetComponent(_currentInstanceId); + if (component is null) + return; + + // Fixed components cannot be removed. + if (component.IsFixed) + return; + + _componentDefId = component.ComponentDefinitionId; + _position = component.Position; + _rotation = component.Rotation; + _pins = component.Pins.ToList(); + _capturedCustomValue = component.CustomValue; + + CaptureConnectedTopology(component); + + _hasCapturedState = true; + + _boardState.RemoveComponent(_currentInstanceId); + } + + /// + /// Restores the removed component and its captured topology. + /// + public void Undo() + { + if (!_hasCapturedState) + return; + + var restored = _boardState.PlaceComponent(_componentDefId, _position, _rotation, _pins, _capturedCustomValue); + _currentInstanceId = restored.InstanceId; + + var netIdMap = new Dictionary(); + foreach (var (capturedNetId, capturedNetName) in _capturedNets) + { + var existingNet = _boardState.GetNet(capturedNetId); + if (existingNet is not null) + { + netIdMap[capturedNetId] = existingNet.NetId; + continue; + } + + var recreatedNet = _boardState.CreateNet(capturedNetName); + netIdMap[capturedNetId] = recreatedNet.NetId; + } + + foreach (var (capturedNetId, start, end) in _capturedTraces) + { + if (!netIdMap.TryGetValue(capturedNetId, out int restoredNetId)) + continue; + + _boardState.AddTrace(restoredNetId, start, end); + } + + foreach (var (capturedNetId, pinIndex) in _capturedPinConnections) + { + if (!netIdMap.TryGetValue(capturedNetId, out int restoredNetId)) + continue; + + var pinRef = new PinReference(_currentInstanceId, pinIndex, restored.GetPinWorldPosition(pinIndex)); + _boardState.ConnectPinToNet(restoredNetId, pinRef); + } + } + + private void CaptureConnectedTopology(PlacedComponent component) + { + _capturedNets.Clear(); + _capturedTraces.Clear(); + _capturedPinConnections.Clear(); + + var capturedNetIds = new HashSet(); + var capturedTraceIds = new HashSet(); + + foreach (var pin in component.Pins) + { + if (!pin.ConnectedNetId.HasValue) + continue; + + int netId = pin.ConnectedNetId.Value; + _capturedPinConnections.Add((netId, pin.PinIndex)); + + if (capturedNetIds.Add(netId)) + { + var net = _boardState.GetNet(netId); + _capturedNets.Add((netId, net?.NetName ?? $"NET{netId}")); + } + + var pinPosition = component.GetPinWorldPosition(pin.PinIndex); + foreach (var trace in _boardState.GetTraces(netId)) + { + if (trace.Start != pinPosition && trace.End != pinPosition) + continue; + + if (capturedTraceIds.Add(trace.SegmentId)) + { + _capturedTraces.Add((netId, trace.Start, trace.End)); + } + } + } + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/60_Commands/10_Board/RemoveComponentCommand.cs.meta b/Assets/10_Scripts/10_Runtime/85_Commands/10_Board/RemoveComponentCommand.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/60_Commands/10_Board/RemoveComponentCommand.cs.meta rename to Assets/10_Scripts/10_Runtime/85_Commands/10_Board/RemoveComponentCommand.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/60_Commands/CircuitCraft.Commands.asmdef b/Assets/10_Scripts/10_Runtime/85_Commands/CircuitCraft.Commands.asmdef similarity index 100% rename from Assets/10_Scripts/10_Runtime/60_Commands/CircuitCraft.Commands.asmdef rename to Assets/10_Scripts/10_Runtime/85_Commands/CircuitCraft.Commands.asmdef diff --git a/Assets/10_Scripts/10_Runtime/60_Commands/CircuitCraft.Commands.asmdef.meta b/Assets/10_Scripts/10_Runtime/85_Commands/CircuitCraft.Commands.asmdef.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/60_Commands/CircuitCraft.Commands.asmdef.meta rename to Assets/10_Scripts/10_Runtime/85_Commands/CircuitCraft.Commands.asmdef.meta diff --git a/Assets/10_Scripts/10_Runtime/85_Commands/CommandHistory.cs b/Assets/10_Scripts/10_Runtime/85_Commands/CommandHistory.cs new file mode 100644 index 0000000..b0b16c5 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/85_Commands/CommandHistory.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; + +namespace CircuitCraft.Commands +{ + /// + /// Maintains undo/redo history for executed board commands. + /// + public class CommandHistory + { + private readonly int _maxCapacity; + private readonly LinkedList _undoStack = new(); + private readonly Stack _redoStack = new(); + + /// + /// Initializes a new command history with an optional maximum undo capacity. + /// + /// Maximum number of commands retained in the undo history. + public CommandHistory(int maxCapacity = 100) + { + _maxCapacity = maxCapacity; + } + + /// + /// Gets a value indicating whether an undo operation is currently available. + /// + public bool CanUndo => _undoStack.Count > 0; + + /// + /// Gets a value indicating whether a redo operation is currently available. + /// + public bool CanRedo => _redoStack.Count > 0; + + /// + /// Raised after a command is executed and added to history. + /// + public event Action OnCommandExecuted; + + /// + /// Raised after a command is undone. + /// + public event Action OnUndo; + + /// + /// Raised after a command is redone. + /// + public event Action OnRedo; + + /// + /// Raised whenever undo/redo history content changes. + /// + public event Action OnHistoryChanged; + + /// + /// Executes a command, stores it in undo history, and clears redo history. + /// + /// Command to execute. + /// Thrown when is null. + public void ExecuteCommand(ICommand command) + { + if (command is null) + throw new ArgumentNullException(nameof(command)); + + command.Execute(); + _undoStack.AddLast(command); + + while (_undoStack.Count > _maxCapacity) + { + _undoStack.RemoveFirst(); + } + + _redoStack.Clear(); + OnCommandExecuted?.Invoke(command); + OnHistoryChanged?.Invoke(); + } + + /// + /// Undoes the most recently executed command if available. + /// + public void Undo() + { + if (!CanUndo) + return; + + var command = _undoStack.Last.Value; + _undoStack.RemoveLast(); + command.Undo(); + _redoStack.Push(command); + OnUndo?.Invoke(command); + OnHistoryChanged?.Invoke(); + } + + /// + /// Redoes the most recently undone command if available. + /// + public void Redo() + { + if (!CanRedo) + return; + + var command = _redoStack.Pop(); + command.Execute(); + _undoStack.AddLast(command); + OnRedo?.Invoke(command); + OnHistoryChanged?.Invoke(); + } + + /// + /// Clears undo and redo histories. + /// + public void Clear() + { + _undoStack.Clear(); + _redoStack.Clear(); + OnHistoryChanged?.Invoke(); + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/60_Commands/CommandHistory.cs.meta b/Assets/10_Scripts/10_Runtime/85_Commands/CommandHistory.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/60_Commands/CommandHistory.cs.meta rename to Assets/10_Scripts/10_Runtime/85_Commands/CommandHistory.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/85_Commands/DeleteTraceNetCommand.cs b/Assets/10_Scripts/10_Runtime/85_Commands/DeleteTraceNetCommand.cs new file mode 100644 index 0000000..a3b4349 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/85_Commands/DeleteTraceNetCommand.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using System.Linq; +using CircuitCraft.Core; + +namespace CircuitCraft.Commands +{ + /// + /// Deletes all traces from a net and restores them on undo. + /// + public class DeleteTraceNetCommand : ICommand + { + private readonly BoardState _boardState; + private readonly int _initialNetId; + + private int _currentNetId; + private string _savedNetName; + private readonly List<(GridPosition start, GridPosition end)> _savedTraces = new(); + private readonly List _savedPins = new(); + private bool _hasCapturedState; + + /// + /// Gets a user-facing description of this trace net deletion command. + /// + public string Description => $"Delete trace net {_initialNetId}"; + + /// + /// Creates a command that removes trace segments for the specified net. + /// + /// The board state to mutate. + /// The target net identifier. + public DeleteTraceNetCommand(BoardState boardState, int netId) + { + _boardState = boardState; + _initialNetId = netId; + _currentNetId = netId; + } + + /// + /// Executes trace deletion for the current net while capturing undo state. + /// + public void Execute() + { + var net = _boardState.GetNet(_currentNetId); + if (net is null) + { + _hasCapturedState = false; + return; + } + + _savedNetName = net.NetName; + + _savedTraces.Clear(); + foreach (var trace in _boardState.GetTraces(_currentNetId).ToList()) + { + _savedTraces.Add((trace.Start, trace.End)); + } + + _savedPins.Clear(); + _savedPins.AddRange(net.ConnectedPins.ToList()); + + _hasCapturedState = true; + + foreach (var trace in _boardState.GetTraces(_currentNetId).ToList()) + { + _boardState.RemoveTrace(trace.SegmentId); + } + } + + /// + /// Restores the previously deleted net traces and pin connections. + /// + public void Undo() + { + if (!_hasCapturedState) + return; + + var recreatedNet = _boardState.CreateNet(_savedNetName); + _currentNetId = recreatedNet.NetId; + + foreach (var (start, end) in _savedTraces) + { + _boardState.AddTrace(_currentNetId, start, end); + } + + foreach (var pin in _savedPins) + { + _boardState.ConnectPinToNet(_currentNetId, pin); + } + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/85_Commands/DeleteTraceNetCommand.cs.meta b/Assets/10_Scripts/10_Runtime/85_Commands/DeleteTraceNetCommand.cs.meta new file mode 100644 index 0000000..452744b --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/85_Commands/DeleteTraceNetCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 452cc8274c6eb3742bcf7cf6c95f7556 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/85_Commands/ICommand.cs b/Assets/10_Scripts/10_Runtime/85_Commands/ICommand.cs new file mode 100644 index 0000000..6702f23 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/85_Commands/ICommand.cs @@ -0,0 +1,23 @@ +namespace CircuitCraft.Commands +{ + /// + /// Defines reversible board mutation behavior for undo/redo workflows. + /// + public interface ICommand + { + /// + /// Applies the command mutation. + /// + void Execute(); + + /// + /// Reverts the mutation applied by . + /// + void Undo(); + + /// + /// Gets a user-facing description of the command. + /// + string Description { get; } + } +} diff --git a/Assets/10_Scripts/10_Runtime/60_Commands/ICommand.cs.meta b/Assets/10_Scripts/10_Runtime/85_Commands/ICommand.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/60_Commands/ICommand.cs.meta rename to Assets/10_Scripts/10_Runtime/85_Commands/ICommand.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/85_Commands/RouteTraceCommand.cs b/Assets/10_Scripts/10_Runtime/85_Commands/RouteTraceCommand.cs new file mode 100644 index 0000000..e89c970 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/85_Commands/RouteTraceCommand.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CircuitCraft.Core; + +namespace CircuitCraft.Commands +{ + /// + /// Command that routes trace segments between two pins and manages net connections. + /// Supports undo/redo through the CommandHistory system. + /// + public class RouteTraceCommand : ICommand + { + private const string GroundNetName = "0"; + private const string GroundNetAlias = "GND"; + private const string GroundComponentDefinitionId = "ground"; + + private readonly BoardState _boardState; + private readonly PinReference _startPin; + private readonly PinReference _endPin; + private readonly List<(GridPosition start, GridPosition end)> _segments; + + // State captured during Execute for Undo + private int _netId; + private string _netName; + private int? _restoredRouteNetId; + private readonly List _addedSegmentIds = new(); + private int? _startPinPreviousNetId; + private int? _endPinPreviousNetId; + + private bool _didMerge; + private int _mergedSourceNetId; + private string _mergedSourceNetName; + private readonly List<(GridPosition start, GridPosition end)> _mergedSourceTraces = new(); + private readonly List _mergedSourcePins = new(); + private readonly List _mergedTargetTraceIds = new(); + + /// + /// Gets a user-facing description of this routing command. + /// + public string Description => $"Route trace from {_startPin} to {_endPin}"; + + /// + /// Creates a new route trace command. + /// + /// The board state to operate on. + /// The starting pin reference. + /// The ending pin reference. + /// Manhattan trace segments to add. + public RouteTraceCommand( + BoardState boardState, + PinReference startPin, + PinReference endPin, + List<(GridPosition start, GridPosition end)> segments) + { + _boardState = boardState; + _startPin = startPin; + _endPin = endPin; + _segments = segments; + } + + /// + /// Executes trace routing, net resolution, and optional net merge behavior. + /// + public void Execute() + { + // Capture previous pin net connections for undo + _startPinPreviousNetId = GetPinConnectedNetId(_startPin); + _endPinPreviousNetId = GetPinConnectedNetId(_endPin); + + _addedSegmentIds.Clear(); + _didMerge = false; + _restoredRouteNetId = null; + + // Resolve which net to use (may create a new one) + _netId = ResolveNetId(); + _netName = _boardState.GetNet(_netId)?.NetName; + + // Connect pins to the net + _boardState.ConnectPinToNet(_netId, _startPin); + _boardState.ConnectPinToNet(_netId, _endPin); + + // Add trace segments + foreach (var segment in _segments) + { + var trace = _boardState.AddTrace(_netId, segment.start, segment.end); + _addedSegmentIds.Add(trace.SegmentId); + } + } + + /// + /// Undoes routed trace segments and restores previous pin and net state. + /// + public void Undo() + { + // Remove all trace segments added by this command (reverse order) + for (int i = _addedSegmentIds.Count - 1; i >= 0; i--) + { + _boardState.RemoveTrace(_addedSegmentIds[i]); + } + _addedSegmentIds.Clear(); + + // BoardState.RemoveTrace auto-cleans the net when no traces remain + // (disconnects all pins, removes net). Check if net still exists. + var net = _boardState.GetNet(_netId); + + if (net is not null) + { + // Net still has other traces. Only disconnect pins that we newly + // connected (skip pins that were already on this net before Execute). + if (!_startPinPreviousNetId.HasValue || _startPinPreviousNetId.Value != _netId) + { + DisconnectPinFromNet(_startPin, net); + } + + if (!_endPinPreviousNetId.HasValue || _endPinPreviousNetId.Value != _netId) + { + DisconnectPinFromNet(_endPin, net); + } + } + + // Restore previous pin connections if they were on different nets + RestorePreviousPinConnection(_startPin, _startPinPreviousNetId); + RestorePreviousPinConnection(_endPin, _endPinPreviousNetId); + + UnmergeNets(); + } + + private int ResolveNetId() + { + int? startNetId = _startPinPreviousNetId; + int? endNetId = _endPinPreviousNetId; + + if (startNetId.HasValue && endNetId.HasValue) + { + if (startNetId.Value != endNetId.Value) + { + int targetNetId = GetPreferredMergeTargetNetId(startNetId.Value, endNetId.Value); + int sourceNetId = targetNetId == startNetId.Value ? endNetId.Value : startNetId.Value; + MergeNets(targetNetId, sourceNetId); + + return targetNetId; + } + + return startNetId.Value; + } + + if (startNetId.HasValue) + { + return startNetId.Value; + } + + if (endNetId.HasValue) + { + return endNetId.Value; + } + + // Neither pin connected — create a new net + string netName = IsGroundPin(_startPin) || IsGroundPin(_endPin) + ? GroundNetName + : $"NET{_boardState.Nets.Count + 1}"; + return _boardState.CreateNet(netName).NetId; + } + + private int GetPreferredMergeTargetNetId(int firstNetId, int secondNetId) + { + var firstNet = _boardState.GetNet(firstNetId); + var secondNet = _boardState.GetNet(secondNetId); + + bool firstIsGround = IsGroundNet(firstNet); + bool secondIsGround = IsGroundNet(secondNet); + + if (firstIsGround && !secondIsGround) + return firstNetId; + + if (!firstIsGround && secondIsGround) + return secondNetId; + + return firstNetId; + } + + private bool IsGroundPin(PinReference pinRef) + { + var component = _boardState.GetComponent(pinRef.ComponentInstanceId); + if (component is null) + return false; + + return string.Equals(component.ComponentDefinitionId, GroundComponentDefinitionId, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsGroundNet(Net net) + => net is not null && IsGroundNetName(net.NetName); + + private static bool IsGroundNetName(string netName) + { + return string.Equals(netName, GroundNetName, StringComparison.OrdinalIgnoreCase) + || string.Equals(netName, GroundNetAlias, StringComparison.OrdinalIgnoreCase); + } + + private void MergeNets(int targetNetId, int sourceNetId) + { + if (targetNetId == sourceNetId) + return; + + var sourceNet = _boardState.GetNet(sourceNetId); + if (sourceNet is null) + return; + + _didMerge = true; + _mergedSourceNetId = sourceNetId; + _mergedSourceNetName = sourceNet.NetName; + + _mergedSourcePins.Clear(); + _mergedSourcePins.AddRange(sourceNet.ConnectedPins.ToList()); + + _mergedSourceTraces.Clear(); + foreach (var trace in _boardState.GetTraces(sourceNetId).ToList()) + { + _mergedSourceTraces.Add((trace.Start, trace.End)); + } + + _mergedTargetTraceIds.Clear(); + foreach (var pin in _mergedSourcePins) + { + _boardState.ConnectPinToNet(targetNetId, pin); + } + + foreach (var trace in _boardState.GetTraces(sourceNetId).ToList()) + { + var newTrace = _boardState.AddTrace(targetNetId, trace.Start, trace.End); + _mergedTargetTraceIds.Add(newTrace.SegmentId); + _boardState.RemoveTrace(trace.SegmentId); + } + } + + private void UnmergeNets() + { + if (!_didMerge) + return; + + foreach (var traceId in _mergedTargetTraceIds) + { + _boardState.RemoveTrace(traceId); + } + _mergedTargetTraceIds.Clear(); + + _boardState.CreateNetWithId(_mergedSourceNetId, _mergedSourceNetName); + + foreach (var (start, end) in _mergedSourceTraces) + { + _boardState.AddTrace(_mergedSourceNetId, start, end); + } + + foreach (var pin in _mergedSourcePins) + { + _boardState.ConnectPinToNet(_mergedSourceNetId, pin); + } + } + + private int? GetPinConnectedNetId(PinReference pinRef) + { + var component = _boardState.GetComponent(pinRef.ComponentInstanceId); + if (component is null) + return null; + + PinInstance pin = null; + foreach (var existingPin in component.Pins) + { + if (existingPin.PinIndex == pinRef.PinIndex) + { + pin = existingPin; + break; + } + } + + return pin?.ConnectedNetId; + } + + private void DisconnectPinFromNet(PinReference pinRef, Net net) + { + net.RemovePin(pinRef); + + var component = _boardState.GetComponent(pinRef.ComponentInstanceId); + if (component is null) + return; + + PinInstance pinInstance = null; + foreach (var existingPin in component.Pins) + { + if (existingPin.PinIndex == pinRef.PinIndex) + { + pinInstance = existingPin; + break; + } + } + + if (pinInstance is not null && pinInstance.ConnectedNetId == _netId) + { + pinInstance.ConnectedNetId = null; + } + } + + private void RestorePreviousPinConnection(PinReference pinRef, int? previousNetId) + { + if (!previousNetId.HasValue) + return; + + int targetNetId = previousNetId.Value; + if (_restoredRouteNetId.HasValue && previousNetId.Value == _netId) + { + targetNetId = _restoredRouteNetId.Value; + } + + var previousNet = _boardState.GetNet(targetNetId); + if (previousNet is null) + { + if (previousNetId.Value != _netId || string.IsNullOrEmpty(_netName)) + return; + + previousNet = _boardState.CreateNet(_netName); + _restoredRouteNetId = previousNet.NetId; + targetNetId = previousNet.NetId; + } + + _boardState.ConnectPinToNet(targetNetId, pinRef); + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/85_Commands/RouteTraceCommand.cs.meta b/Assets/10_Scripts/10_Runtime/85_Commands/RouteTraceCommand.cs.meta new file mode 100644 index 0000000..07a7ead --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/85_Commands/RouteTraceCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8c2919614572b184faf869264d6c21e6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/60_Utils.meta b/Assets/10_Scripts/10_Runtime/90_Utils.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/60_Utils.meta rename to Assets/10_Scripts/10_Runtime/90_Utils.meta diff --git a/Assets/10_Scripts/10_Runtime/50_Data/.gitkeep b/Assets/10_Scripts/10_Runtime/90_Utils/.gitkeep similarity index 100% rename from Assets/10_Scripts/10_Runtime/50_Data/.gitkeep rename to Assets/10_Scripts/10_Runtime/90_Utils/.gitkeep diff --git a/Assets/10_Scripts/10_Runtime/90_Utils/CircuitCraft.Utils.asmdef b/Assets/10_Scripts/10_Runtime/90_Utils/CircuitCraft.Utils.asmdef new file mode 100644 index 0000000..9de4507 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/90_Utils/CircuitCraft.Utils.asmdef @@ -0,0 +1,14 @@ +{ + "name": "CircuitCraft.Utils", + "rootNamespace": "CircuitCraft.Utils", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/10_Scripts/10_Runtime/90_Utils/CircuitCraft.Utils.asmdef.meta b/Assets/10_Scripts/10_Runtime/90_Utils/CircuitCraft.Utils.asmdef.meta new file mode 100644 index 0000000..d3b8e97 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/90_Utils/CircuitCraft.Utils.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ceb287222f994b542a46b442ff21df1c +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/10_Runtime/60_Utils/GridUtility.cs b/Assets/10_Scripts/10_Runtime/90_Utils/GridUtility.cs similarity index 70% rename from Assets/10_Scripts/10_Runtime/60_Utils/GridUtility.cs rename to Assets/10_Scripts/10_Runtime/90_Utils/GridUtility.cs index d25d497..beca531 100644 --- a/Assets/10_Scripts/10_Runtime/60_Utils/GridUtility.cs +++ b/Assets/10_Scripts/10_Runtime/90_Utils/GridUtility.cs @@ -3,8 +3,9 @@ namespace CircuitCraft.Utils { /// - /// Static utility for grid coordinate conversions and validation. + /// Static utility for grid coordinate conversions and suggested area checks. /// Provides methods to convert between screen space, world space, and grid coordinates. + /// The grid is now unbounded - use IsInsideSuggestedArea for UI hints only. /// public static class GridUtility { @@ -19,7 +20,7 @@ public static class GridUtility public static Vector2Int ScreenToGridPosition(Vector3 screenPosition, Camera camera, float cellSize, Vector3 gridOrigin) { Ray ray = camera.ScreenPointToRay(screenPosition); - Plane gridPlane = new Plane(Vector3.up, gridOrigin); + Plane gridPlane = new(Vector3.up, gridOrigin); if (gridPlane.Raycast(ray, out float enter)) { @@ -29,7 +30,7 @@ public static Vector2Int ScreenToGridPosition(Vector3 screenPosition, Camera cam int x = Mathf.RoundToInt((worldPos.x - gridOrigin.x) / cellSize); int z = Mathf.RoundToInt((worldPos.z - gridOrigin.z) / cellSize); - return new Vector2Int(x, z); + return new(x, z); } return Vector2Int.zero; @@ -47,7 +48,7 @@ public static Vector3 GridToWorldPosition(Vector2Int gridPosition, float cellSiz float worldX = gridOrigin.x + (gridPosition.x * cellSize); float worldZ = gridOrigin.z + (gridPosition.y * cellSize); - return new Vector3(worldX, gridOrigin.y, worldZ); + return new(worldX, gridOrigin.y, worldZ); } /// @@ -57,10 +58,25 @@ public static Vector3 GridToWorldPosition(Vector2Int gridPosition, float cellSiz /// Width of the grid (number of cells) /// Height of the grid (number of cells) /// True if position is within grid bounds + [System.Obsolete("Grid is now unbounded. Use IsInsideSuggestedArea for UI hints.")] public static bool IsValidGridPosition(Vector2Int gridPosition, int gridWidth, int gridHeight) { return gridPosition.x >= 0 && gridPosition.x < gridWidth && gridPosition.y >= 0 && gridPosition.y < gridHeight; } + + /// + /// Checks if a grid position is within the suggested build area. + /// This is a UI hint only — placement is NOT restricted to this area. + /// + /// Grid coordinates to check + /// Suggested area width + /// Suggested area height + /// True if position is within the suggested area + public static bool IsInsideSuggestedArea(Vector2Int gridPosition, int suggestedWidth, int suggestedHeight) + { + return gridPosition.x >= 0 && gridPosition.x < suggestedWidth && + gridPosition.y >= 0 && gridPosition.y < suggestedHeight; + } } } diff --git a/Assets/10_Scripts/10_Runtime/60_Utils/GridUtility.cs.meta b/Assets/10_Scripts/10_Runtime/90_Utils/GridUtility.cs.meta similarity index 100% rename from Assets/10_Scripts/10_Runtime/60_Utils/GridUtility.cs.meta rename to Assets/10_Scripts/10_Runtime/90_Utils/GridUtility.cs.meta diff --git a/Assets/10_Scripts/10_Runtime/90_Utils/UIInputHelper.cs b/Assets/10_Scripts/10_Runtime/90_Utils/UIInputHelper.cs new file mode 100644 index 0000000..c4698d7 --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/90_Utils/UIInputHelper.cs @@ -0,0 +1,100 @@ +using UnityEngine; +using UnityEngine.UIElements; + +namespace CircuitCraft.Utils +{ + /// + /// Utility helpers for querying pointer interactions against runtime UI documents. + /// + public static class UIInputHelper + { + /// + /// Checks whether the pointer is currently over any non-root UI element in any provided document. + /// + /// The UI documents to test. + /// True if the pointer is over a visible UI element. + public static bool IsPointerOverUI(UIDocument[] uiDocuments) + { + if (uiDocuments is null) + return false; + + foreach (var doc in uiDocuments) + { + if (doc == null || doc.rootVisualElement == null) + continue; + + var panel = doc.rootVisualElement.panel; + if (panel is null) + continue; + + Vector2 screenPos = Input.mousePosition; + Vector2 panelPos = new(screenPos.x, Screen.height - screenPos.y); + panelPos = RuntimePanelUtils.ScreenToPanel(panel, panelPos); + + var picked = panel.Pick(panelPos); + if (picked is not null + && picked != doc.rootVisualElement + && !(picked is TemplateContainer)) + { + return true; + } + } + + return false; + } + + /// + /// Checks whether the pointer is over interactive UI that is outside the main game view container. + /// + /// The UI documents to test. + /// True if the pointer is over UI and not inside the game view area. + public static bool IsPointerOverRealUI(UIDocument[] uiDocuments) + { + if (uiDocuments is null) + return false; + + foreach (var doc in uiDocuments) + { + if (doc == null || doc.rootVisualElement == null) + continue; + + var panel = doc.rootVisualElement.panel; + if (panel is null) + continue; + + Vector2 screenPos = Input.mousePosition; + Vector2 panelPos = new(screenPos.x, Screen.height - screenPos.y); + panelPos = RuntimePanelUtils.ScreenToPanel(panel, panelPos); + + var picked = panel.Pick(panelPos); + if (picked is null || picked == doc.rootVisualElement || picked is TemplateContainer) + continue; + + var gameView = doc.rootVisualElement.Q("GameView"); + if (gameView is not null && IsChildOf(picked, gameView)) + continue; + + return true; + } + + return false; + } + + private static bool IsChildOf(VisualElement element, VisualElement parent) + { + if (element is null || parent is null) + return false; + + var current = element; + while (current is not null) + { + if (current == parent) + return true; + + current = current.parent; + } + + return false; + } + } +} diff --git a/Assets/10_Scripts/10_Runtime/90_Utils/UIInputHelper.cs.meta b/Assets/10_Scripts/10_Runtime/90_Utils/UIInputHelper.cs.meta new file mode 100644 index 0000000..76df47f --- /dev/null +++ b/Assets/10_Scripts/10_Runtime/90_Utils/UIInputHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 99b4b14bfb44b2b4cb98e3872c5d74f2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/AutoAssignReferences.cs b/Assets/10_Scripts/20_Editor/AutoAssignReferences.cs similarity index 91% rename from Assets/Editor/AutoAssignReferences.cs rename to Assets/10_Scripts/20_Editor/AutoAssignReferences.cs index 2326248..e3ba296 100644 --- a/Assets/Editor/AutoAssignReferences.cs +++ b/Assets/10_Scripts/20_Editor/AutoAssignReferences.cs @@ -3,8 +3,14 @@ using UnityEditor.SceneManagement; using System.Linq; +/// +/// Editor utility to auto-assign scene references via SerializedObject. +/// public static class AutoAssignReferences { + /// + /// Finds required assets and scene objects, assigns references, and saves open scenes. + /// [MenuItem("Tools/CircuitCraft/Auto-Assign Scene References")] public static void AssignAll() { @@ -42,7 +48,7 @@ public static void AssignAll() var comp = go.GetComponent(typeName); if (comp == null) continue; - var so = new SerializedObject(comp); + SerializedObject so = new(comp); var prop = so.FindProperty("_gridSettings"); if (prop == null) { @@ -64,7 +70,7 @@ public static void AssignAll() var comp = go.GetComponent("SimulationManager"); if (comp != null) { - var so = new SerializedObject(comp); + SerializedObject so = new(comp); var prop = so.FindProperty("_componentDefinitions"); if (prop != null && prop.isArray) { @@ -94,7 +100,7 @@ public static void AssignAll() var comp = go.GetComponent("ComponentPaletteController"); if (comp != null) { - var so = new SerializedObject(comp); + SerializedObject so = new(comp); var prop = so.FindProperty("_componentDefinitions"); if (prop != null && prop.isArray) { @@ -122,6 +128,9 @@ public static void AssignAll() Debug.Log($"[AutoAssign] Complete! {assignedCount} assignments made. Scene saved."); } + /// + /// Finds the first active scene object that contains a component with the provided type name. + /// private static GameObject FindGameObjectWithComponent(string typeName) { var allMBs = Object.FindObjectsOfType(); diff --git a/Assets/Editor/AutoAssignReferences.cs.meta b/Assets/10_Scripts/20_Editor/AutoAssignReferences.cs.meta similarity index 100% rename from Assets/Editor/AutoAssignReferences.cs.meta rename to Assets/10_Scripts/20_Editor/AutoAssignReferences.cs.meta diff --git a/Assets/10_Scripts/20_Editor/MainMenuTextureGenerator.cs b/Assets/10_Scripts/20_Editor/MainMenuTextureGenerator.cs new file mode 100644 index 0000000..1ee6b54 --- /dev/null +++ b/Assets/10_Scripts/20_Editor/MainMenuTextureGenerator.cs @@ -0,0 +1,189 @@ +using System; +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace CircuitCraft.Editor +{ + /// + /// Generates and imports main menu icon textures used by the UI. + /// + public static class MainMenuTextureGenerator + { + [Tooltip("Generated icon texture size in pixels.")] + private const int k_IconSize = 128; + + [Tooltip("Output folder for generated main menu textures.")] + private const string k_OutputPath = "Assets/60_UI/10_Sprites/10_MainMenu"; + + /// + /// Generates all main menu icon textures and configures them as sprites. + /// + [MenuItem("Tools/CircuitCraft/Generate MainMenu Textures")] + public static void GenerateAll() + { + if (!Directory.Exists(k_OutputPath)) + { + Directory.CreateDirectory(k_OutputPath); + } + + GenerateIconPlay(); + GenerateIconSettings(); + GenerateIconQuit(); + + AssetDatabase.Refresh(); + ConfigureAsSprite("IconPlay.png"); + ConfigureAsSprite("IconSettings.png"); + ConfigureAsSprite("IconQuit.png"); + AssetDatabase.SaveAssets(); + + Debug.Log("[MainMenuTextureGenerator] Generated 3 icon textures."); + } + + private static void GenerateIconPlay() + { + Vector2 a = new(-22f, 30f); + Vector2 b = new(-22f, -30f); + Vector2 c = new(30f, 0f); + + GenerateTexture("IconPlay.png", p => + { + float sdf = SdfTriangle(p, a, b, c); + return OutlineAlphaFromSignedDistance(sdf, 2f, 1.25f); + }); + } + + private static void GenerateIconSettings() + { + const float holeRadius = 13f; + const float baseOuterRadius = 33f; + const float toothHeight = 5f; + const int teeth = 8; + + GenerateTexture("IconSettings.png", p => + { + float r = p.magnitude; + float theta = Mathf.Atan2(p.y, p.x); + float toothWave = Mathf.Max(0f, Mathf.Cos(theta * teeth)); + float outerRadius = baseOuterRadius + toothWave * toothHeight; + + float dOuter = r - outerRadius; + float dHole = holeRadius - r; + float filledSdf = Mathf.Max(dOuter, dHole); + + return OutlineAlphaFromSignedDistance(filledSdf, 1.7f, 1.2f); + }); + } + + private static void GenerateIconQuit() + { + Vector2 a0 = new(-24f, -24f); + Vector2 b0 = new(24f, 24f); + Vector2 a1 = new(-24f, 24f); + Vector2 b1 = new(24f, -24f); + + GenerateTexture("IconQuit.png", p => + { + float d0 = SdfLineSegment(p, a0, b0); + float d1 = SdfLineSegment(p, a1, b1); + float d = Mathf.Min(d0, d1); + return OutlineAlphaFromUnsignedDistance(d, 2f, 1.25f); + }); + } + + private static void GenerateTexture(string fileName, Func alphaEvaluator) + { + var tex = new Texture2D(k_IconSize, k_IconSize, TextureFormat.RGBA32, false); + var pixels = new Color32[k_IconSize * k_IconSize]; + Vector2 center = new((k_IconSize - 1) * 0.5f, (k_IconSize - 1) * 0.5f); + + for (int y = 0; y < k_IconSize; y++) + { + for (int x = 0; x < k_IconSize; x++) + { + Vector2 p = new(x, y); + p -= center; + float alpha = Mathf.Clamp01(alphaEvaluator(p)); + pixels[y * k_IconSize + x] = new Color32(255, 255, 255, (byte)Mathf.RoundToInt(alpha * 255f)); + } + } + + tex.SetPixels32(pixels); + tex.Apply(false, false); + + File.WriteAllBytes(Path.Combine(k_OutputPath, fileName), tex.EncodeToPNG()); + UnityEngine.Object.DestroyImmediate(tex); + } + + private static float OutlineAlphaFromSignedDistance(float sdf, float halfLineWidth, float aaWidth) + { + float edge = Mathf.Abs(sdf) - halfLineWidth; + return 1f - Smoothstep01(edge / aaWidth); + } + + private static float OutlineAlphaFromUnsignedDistance(float distance, float halfLineWidth, float aaWidth) + { + float edge = distance - halfLineWidth; + return 1f - Smoothstep01(edge / aaWidth); + } + + private static float Smoothstep01(float t) + { + t = Mathf.Clamp01(t); + return t * t * (3f - 2f * t); + } + + private static float SdfLineSegment(Vector2 p, Vector2 a, Vector2 b) + { + Vector2 pa = p - a; + Vector2 ba = b - a; + float h = Mathf.Clamp01(Vector2.Dot(pa, ba) / Vector2.Dot(ba, ba)); + return (pa - ba * h).magnitude; + } + + private static float SdfTriangle(Vector2 p, Vector2 a, Vector2 b, Vector2 c) + { + float d0 = SdfLineSegment(p, a, b); + float d1 = SdfLineSegment(p, b, c); + float d2 = SdfLineSegment(p, c, a); + float edgeDistance = Mathf.Min(d0, Mathf.Min(d1, d2)); + + bool inside = IsPointInTriangle(p, a, b, c); + return inside ? -edgeDistance : edgeDistance; + } + + private static bool IsPointInTriangle(Vector2 p, Vector2 a, Vector2 b, Vector2 c) + { + float s0 = CrossZ(b - a, p - a); + float s1 = CrossZ(c - b, p - b); + float s2 = CrossZ(a - c, p - c); + + bool hasNeg = (s0 < 0f) || (s1 < 0f) || (s2 < 0f); + bool hasPos = (s0 > 0f) || (s1 > 0f) || (s2 > 0f); + return !(hasNeg && hasPos); + } + + private static float CrossZ(Vector2 a, Vector2 b) + { + return (a.x * b.y) - (a.y * b.x); + } + + private static void ConfigureAsSprite(string fileName) + { + string assetPath = k_OutputPath + "/" + fileName; + AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate); + + var importer = AssetImporter.GetAtPath(assetPath) as TextureImporter; + if (importer == null) + { + return; + } + + importer.textureType = TextureImporterType.Sprite; + importer.spriteImportMode = SpriteImportMode.Single; + importer.alphaIsTransparency = true; + importer.mipmapEnabled = false; + importer.SaveAndReimport(); + } + } +} diff --git a/Assets/10_Scripts/20_Editor/MainMenuTextureGenerator.cs.meta b/Assets/10_Scripts/20_Editor/MainMenuTextureGenerator.cs.meta new file mode 100644 index 0000000..5c40f6e --- /dev/null +++ b/Assets/10_Scripts/20_Editor/MainMenuTextureGenerator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 061d00e7d7f5a1b49a6939b31651f334 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/20_Tests.meta b/Assets/10_Scripts/30_Tests.meta similarity index 100% rename from Assets/10_Scripts/20_Tests.meta rename to Assets/10_Scripts/30_Tests.meta diff --git a/Assets/10_Scripts/30_Tests/10_Core.meta b/Assets/10_Scripts/30_Tests/10_Core.meta new file mode 100644 index 0000000..6adc5f6 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/10_Core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3a9dcfa0c2d01bb42b28b47a7a7550aa +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/10_Core/BoardStateTests.cs b/Assets/10_Scripts/30_Tests/10_Core/BoardStateTests.cs new file mode 100644 index 0000000..04e9fb4 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/10_Core/BoardStateTests.cs @@ -0,0 +1,1118 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Linq; +using CircuitCraft.Core; + +namespace CircuitCraft.Tests.Core +{ + /// + /// Characterization tests for BoardState — captures ALL current behavior as a safety net + /// before any refactoring. Tests must pass against the CURRENT implementation as-is. + /// + [TestFixture] + public class BoardStateTests + { + private BoardState _board; + + [SetUp] + public void SetUp() + { + _board = new BoardState(10, 10); + } + + #region Helper Methods + + private static List CreateTestPins(int count) + { + var pins = new List(); + for (int i = 0; i < count; i++) + pins.Add(new PinInstance(i, $"pin{i}", new GridPosition(i, 0))); + return pins; + } + + private PlacedComponent PlaceComponentAt(int x, int y, string defId = "test_comp", int pinCount = 2) + { + return _board.PlaceComponent(defId, new GridPosition(x, y), 0, CreateTestPins(pinCount)); + } + + private PlacedComponent PlaceFixedComponentAt(int x, int y, string defId = "test_comp") + { + return _board.PlaceComponent(defId, new GridPosition(x, y), 0, CreateTestPins(2), null, isFixed: true); + } + + #endregion + + #region Constructor Tests + + [Test] + public void Constructor_CreatesBoardWithCorrectSuggestedBounds() + { + var board = new BoardState(10, 10); + + Assert.AreEqual(10, board.SuggestedBounds.Width); + Assert.AreEqual(10, board.SuggestedBounds.Height); + Assert.AreEqual(0, board.SuggestedBounds.MinX); + Assert.AreEqual(0, board.SuggestedBounds.MinY); + } + + [Test] + public void Constructor_CreatesBoardWithNonSquareDimensions() + { + var board = new BoardState(20, 5); + + Assert.AreEqual(20, board.SuggestedBounds.Width); + Assert.AreEqual(5, board.SuggestedBounds.Height); + } + + [Test] + public void Constructor_ComponentsAreInitiallyEmpty() + { + Assert.IsNotNull(_board.Components); + Assert.AreEqual(0, _board.Components.Count); + } + + [Test] + public void Constructor_NetsAreInitiallyEmpty() + { + Assert.IsNotNull(_board.Nets); + Assert.AreEqual(0, _board.Nets.Count); + } + + [Test] + public void Constructor_TracesAreInitiallyEmpty() + { + Assert.IsNotNull(_board.Traces); + Assert.AreEqual(0, _board.Traces.Count); + } + + [Test] + public void Constructor_BoundsAliasSuggestedBounds() + { + var board = new BoardState(8, 6); + Assert.AreEqual(board.SuggestedBounds, board.Bounds); + } + + #endregion + + #region PlaceComponent Tests + + [Test] + public void PlaceComponent_ReturnsPlacedComponentWithCorrectProperties() + { + var pins = CreateTestPins(2); + var comp = _board.PlaceComponent("resistor_1k", new GridPosition(3, 4), 90, pins); + + Assert.IsNotNull(comp); + Assert.AreEqual("resistor_1k", comp.ComponentDefinitionId); + Assert.AreEqual(new GridPosition(3, 4), comp.Position); + Assert.AreEqual(90, comp.Rotation); + } + + [Test] + public void PlaceComponent_AssignsAutoIncrementedInstanceId_StartingFromOne() + { + var comp1 = PlaceComponentAt(0, 0); + var comp2 = PlaceComponentAt(5, 5); + + Assert.AreEqual(1, comp1.InstanceId); + Assert.AreEqual(2, comp2.InstanceId); + } + + [Test] + public void PlaceComponent_StoresCustomValue() + { + var comp = _board.PlaceComponent("resistor", new GridPosition(0, 0), 0, CreateTestPins(2), customValue: 4700f); + + Assert.IsTrue(comp.CustomValue.HasValue); + Assert.AreEqual(4700f, comp.CustomValue.Value, 0.001f); + } + + [Test] + public void PlaceComponent_NullCustomValueIsPreserved() + { + var comp = _board.PlaceComponent("resistor", new GridPosition(0, 0), 0, CreateTestPins(2), customValue: null); + + Assert.IsFalse(comp.CustomValue.HasValue); + } + + [Test] + public void PlaceComponent_StoresIsFixedFlag_WhenTrue() + { + var comp = PlaceFixedComponentAt(0, 0); + + Assert.IsTrue(comp.IsFixed); + } + + [Test] + public void PlaceComponent_StoresIsFixedFlag_WhenFalse() + { + var comp = PlaceComponentAt(0, 0); + + Assert.IsFalse(comp.IsFixed); + } + + [Test] + public void PlaceComponent_PinInstancesAreStoredCorrectly() + { + var pins = new List + { + new PinInstance(0, "anode", new GridPosition(0, 0)), + new PinInstance(1, "cathode", new GridPosition(1, 0)) + }; + var comp = _board.PlaceComponent("diode", new GridPosition(2, 2), 0, pins); + + Assert.AreEqual(2, comp.Pins.Count); + Assert.AreEqual("anode", comp.Pins[0].PinName); + Assert.AreEqual("cathode", comp.Pins[1].PinName); + } + + [Test] + public void PlaceComponent_AddsComponentToComponentsList() + { + PlaceComponentAt(0, 0); + PlaceComponentAt(3, 3); + + Assert.AreEqual(2, _board.Components.Count); + } + + [Test] + public void PlaceComponent_DuplicatePosition_ThrowsInvalidOperationException() + { + PlaceComponentAt(2, 2); + + Assert.Throws(() => PlaceComponentAt(2, 2)); + } + + [Test] + public void PlaceComponent_ValidRotations_AreAccepted() + { + // 0, 90, 180, 270 are all valid + var c0 = _board.PlaceComponent("comp", new GridPosition(0, 0), 0, CreateTestPins(1)); + var c90 = _board.PlaceComponent("comp", new GridPosition(2, 0), 90, CreateTestPins(1)); + var c180 = _board.PlaceComponent("comp", new GridPosition(4, 0), 180, CreateTestPins(1)); + var c270 = _board.PlaceComponent("comp", new GridPosition(6, 0), 270, CreateTestPins(1)); + + Assert.AreEqual(0, c0.Rotation); + Assert.AreEqual(90, c90.Rotation); + Assert.AreEqual(180, c180.Rotation); + Assert.AreEqual(270, c270.Rotation); + } + + #endregion + + #region RemoveComponent Tests + + [Test] + public void RemoveComponent_SuccessfullyRemovesExistingComponent_ReturnsTrue() + { + var comp = PlaceComponentAt(0, 0); + + bool result = _board.RemoveComponent(comp.InstanceId); + + Assert.IsTrue(result); + Assert.AreEqual(0, _board.Components.Count); + } + + [Test] + public void RemoveComponent_NonExistentId_ReturnsFalse() + { + bool result = _board.RemoveComponent(9999); + + Assert.IsFalse(result); + } + + [Test] + public void RemoveComponent_FixedComponent_ReturnsFalse_AndDoesNotRemove() + { + var comp = PlaceFixedComponentAt(0, 0); + + bool result = _board.RemoveComponent(comp.InstanceId); + + Assert.IsFalse(result); + Assert.AreEqual(1, _board.Components.Count, "Fixed component should still be present"); + } + + [Test] + public void RemoveComponent_ClearsPinConnectedNetId() + { + var comp = PlaceComponentAt(0, 0); + var net = _board.CreateNet("VIN"); + var pinRef = new PinReference(comp.InstanceId, 0, comp.GetPinWorldPosition(0)); + _board.ConnectPinToNet(net.NetId, pinRef); + + // Verify pin is connected + Assert.IsTrue(comp.Pins[0].ConnectedNetId.HasValue); + + _board.RemoveComponent(comp.InstanceId); + + // After removal, net should be auto-deleted (no pins left) — verify net is gone + Assert.IsNull(_board.GetNet(net.NetId)); + } + + [Test] + public void RemoveComponent_RemovesComponentFromPosition_PositionBecomesVacant() + { + var comp = PlaceComponentAt(3, 3); + + _board.RemoveComponent(comp.InstanceId); + + Assert.IsFalse(_board.IsPositionOccupied(new GridPosition(3, 3))); + } + + [Test] + public void RemoveComponent_RemovesTracesConnectedToComponentPins() + { + // Place component with pin at (0,0) local which maps to world (2,2) + (0,0) = (2,2) + var comp = _board.PlaceComponent("comp", new GridPosition(2, 2), 0, + new[] { new PinInstance(0, "pin0", new GridPosition(0, 0)) }); + var net = _board.CreateNet("NET1"); + // Add a trace that starts at the pin world position + var pinWorldPos = comp.GetPinWorldPosition(0); // (2,2) + var trace = _board.AddTrace(net.NetId, pinWorldPos, new GridPosition(pinWorldPos.X + 3, pinWorldPos.Y)); + + _board.RemoveComponent(comp.InstanceId); + + // The trace touching the pin should be removed + Assert.AreEqual(0, _board.Traces.Count, "Traces touching removed component pins should be auto-removed"); + } + + [Test] + public void RemoveComponent_AutoDeletesOrphanedNets_WhenNoPinsRemain() + { + var comp = PlaceComponentAt(0, 0); + var net = _board.CreateNet("ORPHAN"); + _board.ConnectPinToNet(net.NetId, new PinReference(comp.InstanceId, 0, comp.GetPinWorldPosition(0))); + + Assert.AreEqual(1, _board.Nets.Count); + + _board.RemoveComponent(comp.InstanceId); + + // Net with no remaining connected pins should be auto-deleted + Assert.IsNull(_board.GetNet(net.NetId)); + } + + [Test] + public void RemoveComponent_DoesNotDeleteNet_WhenOtherPinsStillConnected() + { + var comp1 = PlaceComponentAt(0, 0); + var comp2 = PlaceComponentAt(5, 5); + var net = _board.CreateNet("SHARED"); + _board.ConnectPinToNet(net.NetId, new PinReference(comp1.InstanceId, 0, comp1.GetPinWorldPosition(0))); + _board.ConnectPinToNet(net.NetId, new PinReference(comp2.InstanceId, 0, comp2.GetPinWorldPosition(0))); + + _board.RemoveComponent(comp1.InstanceId); + + // Net still has comp2's pin, so it should survive + Assert.IsNotNull(_board.GetNet(net.NetId), "Net with remaining pins should not be deleted"); + } + + #endregion + + #region CreateNet Tests + + [Test] + public void CreateNet_ReturnsNetWithCorrectName() + { + var net = _board.CreateNet("VIN"); + + Assert.IsNotNull(net); + Assert.AreEqual("VIN", net.NetName); + } + + [Test] + public void CreateNet_AssignsAutoIncrementedNetId_StartingFromOne() + { + var net1 = _board.CreateNet("NET1"); + var net2 = _board.CreateNet("NET2"); + + Assert.AreEqual(1, net1.NetId); + Assert.AreEqual(2, net2.NetId); + } + + [Test] + public void CreateNet_AddsNetToNetsList() + { + _board.CreateNet("A"); + _board.CreateNet("B"); + _board.CreateNet("C"); + + Assert.AreEqual(3, _board.Nets.Count); + } + + [Test] + public void CreateNet_CanCreateMultipleNets() + { + var vin = _board.CreateNet("VIN"); + var gnd = _board.CreateNet("GND"); + var vout = _board.CreateNet("VOUT"); + + Assert.AreNotEqual(vin.NetId, gnd.NetId); + Assert.AreNotEqual(gnd.NetId, vout.NetId); + Assert.AreNotEqual(vin.NetId, vout.NetId); + } + + [Test] + public void CreateNet_NewNet_HasNoConnectedPins() + { + var net = _board.CreateNet("EMPTY"); + + Assert.AreEqual(0, net.ConnectedPins.Count); + } + + [Test] + public void Net_IsGround_TrueForGNDName() + { + var gnd = _board.CreateNet("GND"); + Assert.IsTrue(gnd.IsGround); + } + + [Test] + public void Net_IsGround_TrueForZeroName() + { + var zero = _board.CreateNet("0"); + Assert.IsTrue(zero.IsGround); + } + + [Test] + public void Net_IsGround_FalseForOtherNames() + { + var vin = _board.CreateNet("VIN"); + Assert.IsFalse(vin.IsGround); + } + + [Test] + public void Net_IsPower_TrueForVPrefixedName() + { + var vin = _board.CreateNet("VIN"); + Assert.IsTrue(vin.IsPower); + } + + [Test] + public void Net_IsPower_FalseForNonVPrefixedName() + { + var gnd = _board.CreateNet("GND"); + Assert.IsFalse(gnd.IsPower); + } + + #endregion + + #region ConnectPinToNet Tests + + [Test] + public void ConnectPinToNet_UpdatesPinConnectedNetId() + { + var comp = PlaceComponentAt(0, 0); + var net = _board.CreateNet("VIN"); + var pinRef = new PinReference(comp.InstanceId, 0, comp.GetPinWorldPosition(0)); + + _board.ConnectPinToNet(net.NetId, pinRef); + + Assert.IsTrue(comp.Pins[0].ConnectedNetId.HasValue); + Assert.AreEqual(net.NetId, comp.Pins[0].ConnectedNetId.Value); + } + + [Test] + public void ConnectPinToNet_AddsToNetConnectedPins() + { + var comp = PlaceComponentAt(0, 0); + var net = _board.CreateNet("VIN"); + var pinRef = new PinReference(comp.InstanceId, 0, comp.GetPinWorldPosition(0)); + + _board.ConnectPinToNet(net.NetId, pinRef); + + Assert.AreEqual(1, net.ConnectedPins.Count); + } + + [Test] + public void ConnectPinToNet_WhenPinAlreadyOnDifferentNet_DisconnectsFromPreviousNet() + { + var comp = PlaceComponentAt(0, 0); + var netA = _board.CreateNet("NET_A"); + var netB = _board.CreateNet("NET_B"); + var pinRef = new PinReference(comp.InstanceId, 0, comp.GetPinWorldPosition(0)); + + _board.ConnectPinToNet(netA.NetId, pinRef); + Assert.AreEqual(1, netA.ConnectedPins.Count); + + _board.ConnectPinToNet(netB.NetId, pinRef); + + // Pin should no longer be on netA + Assert.AreEqual(0, netA.ConnectedPins.Count, "Pin should be removed from previous net"); + // Pin should be on netB + Assert.AreEqual(1, netB.ConnectedPins.Count); + Assert.AreEqual(netB.NetId, comp.Pins[0].ConnectedNetId.Value); + } + + [Test] + public void ConnectPinToNet_AutoDeletesPreviousNet_WhenItBecomesEmpty() + { + var comp = PlaceComponentAt(0, 0); + var netA = _board.CreateNet("NET_A"); + var netB = _board.CreateNet("NET_B"); + var pinRef = new PinReference(comp.InstanceId, 0, comp.GetPinWorldPosition(0)); + + _board.ConnectPinToNet(netA.NetId, pinRef); + // netA now has exactly 1 pin; moving it should orphan netA + _board.ConnectPinToNet(netB.NetId, pinRef); + + Assert.IsNull(_board.GetNet(netA.NetId), "Empty previous net should be auto-deleted"); + } + + [Test] + public void ConnectPinToNet_SameNet_DoesNotAddDuplicate() + { + var comp = PlaceComponentAt(0, 0); + var net = _board.CreateNet("VIN"); + var pinRef = new PinReference(comp.InstanceId, 0, comp.GetPinWorldPosition(0)); + + _board.ConnectPinToNet(net.NetId, pinRef); + _board.ConnectPinToNet(net.NetId, pinRef); // connect again to same net + + // Should not duplicate + Assert.AreEqual(1, net.ConnectedPins.Count); + } + + [Test] + public void ConnectPinToNet_InvalidNetId_ThrowsArgumentException() + { + var comp = PlaceComponentAt(0, 0); + var pinRef = new PinReference(comp.InstanceId, 0, comp.GetPinWorldPosition(0)); + + Assert.Throws(() => _board.ConnectPinToNet(9999, pinRef)); + } + + [Test] + public void ConnectPinToNet_InvalidComponentId_ThrowsArgumentException() + { + var net = _board.CreateNet("VIN"); + var pinRef = new PinReference(9999, 0, new GridPosition(0, 0)); + + Assert.Throws(() => _board.ConnectPinToNet(net.NetId, pinRef)); + } + + #endregion + + #region AddTrace Tests + + [Test] + public void AddTrace_ReturnsTraceWithCorrectProperties() + { + var net = _board.CreateNet("VIN"); + var trace = _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(0, 3)); + + Assert.IsNotNull(trace); + Assert.AreEqual(net.NetId, trace.NetId); + Assert.AreEqual(new GridPosition(0, 0), trace.Start); + Assert.AreEqual(new GridPosition(0, 3), trace.End); + } + + [Test] + public void AddTrace_AssignsAutoIncrementedSegmentId_StartingFromOne() + { + var net = _board.CreateNet("VIN"); + var t1 = _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(0, 3)); + var t2 = _board.AddTrace(net.NetId, new GridPosition(1, 0), new GridPosition(1, 3)); + + Assert.AreEqual(1, t1.SegmentId); + Assert.AreEqual(2, t2.SegmentId); + } + + [Test] + public void AddTrace_AddsToTracesList() + { + var net = _board.CreateNet("VIN"); + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(3, 0)); + + Assert.AreEqual(1, _board.Traces.Count); + } + + [Test] + public void AddTrace_InvalidNetId_ThrowsArgumentException() + { + Assert.Throws(() => + _board.AddTrace(9999, new GridPosition(0, 0), new GridPosition(0, 3))); + } + + [Test] + public void AddTrace_HorizontalTrace_IsAccepted() + { + var net = _board.CreateNet("H"); + var trace = _board.AddTrace(net.NetId, new GridPosition(0, 5), new GridPosition(5, 5)); + + Assert.AreEqual(new GridPosition(0, 5), trace.Start); + Assert.AreEqual(new GridPosition(5, 5), trace.End); + } + + [Test] + public void AddTrace_VerticalTrace_IsAccepted() + { + var net = _board.CreateNet("V"); + var trace = _board.AddTrace(net.NetId, new GridPosition(3, 0), new GridPosition(3, 7)); + + Assert.AreEqual(new GridPosition(3, 0), trace.Start); + Assert.AreEqual(new GridPosition(3, 7), trace.End); + } + + #endregion + + #region RemoveTrace Tests + + [Test] + public void RemoveTrace_SuccessfullyRemovesTrace_ReturnsTrue() + { + var net = _board.CreateNet("VIN"); + var trace = _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(0, 3)); + + bool result = _board.RemoveTrace(trace.SegmentId); + + Assert.IsTrue(result); + Assert.AreEqual(0, _board.Traces.Count); + } + + [Test] + public void RemoveTrace_NonExistentSegmentId_ReturnsFalse() + { + bool result = _board.RemoveTrace(9999); + + Assert.IsFalse(result); + } + + [Test] + public void RemoveTrace_WhenNetHasNoMoreTraces_AndNoConnectedPins_AutoDeletesNet() + { + var net = _board.CreateNet("ORPHAN_NET"); + var trace = _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(0, 3)); + + _board.RemoveTrace(trace.SegmentId); + + // Net with no more traces and no pins should be auto-deleted + Assert.IsNull(_board.GetNet(net.NetId), "Net with no traces and no pins should be auto-deleted"); + } + + [Test] + public void RemoveTrace_WhenNetHasNoMoreTraces_ClearsPinConnections() + { + var comp = PlaceComponentAt(0, 0); + var net = _board.CreateNet("NET1"); + _board.ConnectPinToNet(net.NetId, new PinReference(comp.InstanceId, 0, comp.GetPinWorldPosition(0))); + var trace = _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(0, 3)); + + _board.RemoveTrace(trace.SegmentId); + + // Pin should have its connection cleared when the last trace is removed + Assert.IsNull(comp.Pins[0].ConnectedNetId, + "Pin ConnectedNetId should be cleared when last trace is removed"); + } + + [Test] + public void RemoveTrace_WhenNetStillHasOtherTraces_DoesNotDeleteNet() + { + var net = _board.CreateNet("NET1"); + var t1 = _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(0, 3)); + var t2 = _board.AddTrace(net.NetId, new GridPosition(0, 3), new GridPosition(5, 3)); + + _board.RemoveTrace(t1.SegmentId); + + Assert.IsNotNull(_board.GetNet(net.NetId), "Net should survive while it still has traces"); + Assert.AreEqual(1, _board.Traces.Count); + } + + [Test] + public void RemoveTrace_MultipleTraces_OnlyRemovesTargetTrace() + { + var net = _board.CreateNet("NET1"); + var t1 = _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(0, 3)); + var t2 = _board.AddTrace(net.NetId, new GridPosition(0, 3), new GridPosition(5, 3)); + var t3 = _board.AddTrace(net.NetId, new GridPosition(5, 3), new GridPosition(5, 0)); + + _board.RemoveTrace(t2.SegmentId); + + Assert.AreEqual(2, _board.Traces.Count); + Assert.IsNotNull(_board.Traces.FirstOrDefault(t => t.SegmentId == t1.SegmentId)); + Assert.IsNull(_board.Traces.FirstOrDefault(t => t.SegmentId == t2.SegmentId)); + Assert.IsNotNull(_board.Traces.FirstOrDefault(t => t.SegmentId == t3.SegmentId)); + } + + #endregion + + #region GetComponent / GetComponentAt / IsPositionOccupied Tests + + [Test] + public void GetComponent_ReturnsCorrectComponent_ByInstanceId() + { + var comp = PlaceComponentAt(2, 3); + + var found = _board.GetComponent(comp.InstanceId); + + Assert.IsNotNull(found); + Assert.AreEqual(comp.InstanceId, found.InstanceId); + Assert.AreEqual(new GridPosition(2, 3), found.Position); + } + + [Test] + public void GetComponent_ReturnsNull_ForNonExistentInstanceId() + { + var found = _board.GetComponent(9999); + + Assert.IsNull(found); + } + + [Test] + public void GetComponentAt_ReturnsComponent_AtOccupiedPosition() + { + var comp = PlaceComponentAt(4, 6); + + var found = _board.GetComponentAt(new GridPosition(4, 6)); + + Assert.IsNotNull(found); + Assert.AreEqual(comp.InstanceId, found.InstanceId); + } + + [Test] + public void GetComponentAt_ReturnsNull_AtEmptyPosition() + { + var found = _board.GetComponentAt(new GridPosition(7, 7)); + + Assert.IsNull(found); + } + + [Test] + public void IsPositionOccupied_ReturnsTrue_WhenComponentPlacedAt() + { + PlaceComponentAt(3, 3); + + Assert.IsTrue(_board.IsPositionOccupied(new GridPosition(3, 3))); + } + + [Test] + public void IsPositionOccupied_ReturnsFalse_WhenNoComponentAt() + { + Assert.IsFalse(_board.IsPositionOccupied(new GridPosition(3, 3))); + } + + [Test] + public void IsPositionOccupied_ReturnsFalse_AfterComponentRemoved() + { + var comp = PlaceComponentAt(3, 3); + _board.RemoveComponent(comp.InstanceId); + + Assert.IsFalse(_board.IsPositionOccupied(new GridPosition(3, 3))); + } + + #endregion + + #region GetNet / GetNetByName Tests + + [Test] + public void GetNet_ReturnsCorrectNet_ByNetId() + { + var net = _board.CreateNet("VIN"); + + var found = _board.GetNet(net.NetId); + + Assert.IsNotNull(found); + Assert.AreEqual("VIN", found.NetName); + } + + [Test] + public void GetNet_ReturnsNull_ForNonExistentNetId() + { + var found = _board.GetNet(9999); + + Assert.IsNull(found); + } + + [Test] + public void GetNetByName_ReturnsCorrectNet_ByName() + { + _board.CreateNet("VIN"); + _board.CreateNet("GND"); + var vout = _board.CreateNet("VOUT"); + + var found = _board.GetNetByName("VOUT"); + + Assert.IsNotNull(found); + Assert.AreEqual(vout.NetId, found.NetId); + } + + [Test] + public void GetNetByName_ReturnsNull_WhenNameNotFound() + { + _board.CreateNet("VIN"); + + var found = _board.GetNetByName("NONEXISTENT"); + + Assert.IsNull(found); + } + + [Test] + public void GetNetByName_IsCaseSensitive() + { + _board.CreateNet("VIN"); + + var found = _board.GetNetByName("vin"); + + // Name lookup is case-sensitive (FirstOrDefault with ==) + Assert.IsNull(found); + } + + #endregion + + #region GetTraces Tests + + [Test] + public void GetTraces_ReturnsTracesForGivenNetId() + { + var net1 = _board.CreateNet("NET1"); + var net2 = _board.CreateNet("NET2"); + _board.AddTrace(net1.NetId, new GridPosition(0, 0), new GridPosition(0, 3)); + _board.AddTrace(net1.NetId, new GridPosition(0, 3), new GridPosition(5, 3)); + _board.AddTrace(net2.NetId, new GridPosition(1, 1), new GridPosition(1, 5)); + + var traces = _board.GetTraces(net1.NetId); + + Assert.AreEqual(2, traces.Count); + Assert.IsTrue(traces.All(t => t.NetId == net1.NetId)); + } + + [Test] + public void GetTraces_ReturnsEmptyList_ForNetWithNoTraces() + { + var net = _board.CreateNet("EMPTY"); + + var traces = _board.GetTraces(net.NetId); + + Assert.IsNotNull(traces); + Assert.AreEqual(0, traces.Count); + } + + [Test] + public void GetTraces_ReturnsEmptyList_ForNonExistentNetId() + { + var traces = _board.GetTraces(9999); + + Assert.IsNotNull(traces); + Assert.AreEqual(0, traces.Count); + } + + #endregion + + #region ComputeContentBounds Tests + + [Test] + public void ComputeContentBounds_EmptyBoard_ReturnsSuggestedBounds() + { + var bounds = _board.ComputeContentBounds(); + + Assert.AreEqual(_board.SuggestedBounds, bounds); + } + + [Test] + public void ComputeContentBounds_WithSingleComponent_ReturnsBoundsEnclosingIt() + { + // Place component at (3,4), pins at local (0,0) and (1,0) -> world (3,4) and (4,4) + var pins = new List + { + new PinInstance(0, "p0", new GridPosition(0, 0)), + new PinInstance(1, "p1", new GridPosition(1, 0)) + }; + _board.PlaceComponent("comp", new GridPosition(3, 4), 0, pins); + + var bounds = _board.ComputeContentBounds(); + + // Component origin is (3,4), pins at world (3,4) and (4,4) + Assert.LessOrEqual(bounds.MinX, 3); + Assert.LessOrEqual(bounds.MinY, 4); + Assert.GreaterOrEqual(bounds.MaxX, 5); // MaxX is exclusive, rightmost pin is x=4, so MaxX >= 5 + Assert.GreaterOrEqual(bounds.MaxY, 5); // MaxY is exclusive, highest Y=4, so MaxY >= 5 + } + + [Test] + public void ComputeContentBounds_WithMultipleComponents_ReturnsEnclosingBounds() + { + PlaceComponentAt(0, 0); + PlaceComponentAt(8, 8); + + var bounds = _board.ComputeContentBounds(); + + Assert.LessOrEqual(bounds.MinX, 0); + Assert.LessOrEqual(bounds.MinY, 0); + Assert.GreaterOrEqual(bounds.MaxX, 8); + Assert.GreaterOrEqual(bounds.MaxY, 8); + } + + [Test] + public void ComputeContentBounds_IncludesTraceEndpoints() + { + var net = _board.CreateNet("VIN"); + // Add a trace from (0,0) to (9,0) — which extends to the edge + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(9, 0)); + + var bounds = _board.ComputeContentBounds(); + + Assert.LessOrEqual(bounds.MinX, 0); + Assert.GreaterOrEqual(bounds.MaxX, 9); // must encompass x=9 + } + + [Test] + public void ComputeContentBounds_IncludesComponentPinPositions() + { + // Place component at (5,5) with pins at local (2,0) -> world (7,5) + var pins = new List + { + new PinInstance(0, "p", new GridPosition(2, 0)) + }; + _board.PlaceComponent("comp", new GridPosition(5, 5), 0, pins); + + var bounds = _board.ComputeContentBounds(); + + // Pin world pos is (7,5); bounds must cover it + Assert.LessOrEqual(bounds.MinX, 5); + Assert.GreaterOrEqual(bounds.MaxX, 8); // x=7 must be inside, MaxX is exclusive so >= 8 + } + + #endregion + + #region Event Firing Tests + + [Test] + public void OnComponentPlaced_FiresWhenComponentPlaced() + { + PlacedComponent capturedComponent = null; + _board.OnComponentPlaced += c => capturedComponent = c; + + var comp = PlaceComponentAt(0, 0); + + Assert.IsNotNull(capturedComponent); + Assert.AreEqual(comp.InstanceId, capturedComponent.InstanceId); + } + + [Test] + public void OnComponentRemoved_FiresWhenComponentRemoved() + { + int capturedInstanceId = -1; + var comp = PlaceComponentAt(0, 0); + _board.OnComponentRemoved += id => capturedInstanceId = id; + + _board.RemoveComponent(comp.InstanceId); + + Assert.AreEqual(comp.InstanceId, capturedInstanceId); + } + + [Test] + public void OnComponentRemoved_DoesNotFire_WhenRemovingFixedComponent() + { + bool fired = false; + var comp = PlaceFixedComponentAt(0, 0); + _board.OnComponentRemoved += _ => fired = true; + + _board.RemoveComponent(comp.InstanceId); + + Assert.IsFalse(fired, "OnComponentRemoved should NOT fire for fixed components"); + } + + [Test] + public void OnComponentRemoved_DoesNotFire_WhenRemovingNonExistentComponent() + { + bool fired = false; + _board.OnComponentRemoved += _ => fired = true; + + _board.RemoveComponent(9999); + + Assert.IsFalse(fired); + } + + [Test] + public void OnNetCreated_FiresWhenNetCreated() + { + Net capturedNet = null; + _board.OnNetCreated += n => capturedNet = n; + + var net = _board.CreateNet("VIN"); + + Assert.IsNotNull(capturedNet); + Assert.AreEqual("VIN", capturedNet.NetName); + } + + [Test] + public void OnTraceAdded_FiresWhenTraceAdded() + { + TraceSegment capturedTrace = null; + _board.OnTraceAdded += t => capturedTrace = t; + var net = _board.CreateNet("VIN"); + + var trace = _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(0, 3)); + + Assert.IsNotNull(capturedTrace); + Assert.AreEqual(trace.SegmentId, capturedTrace.SegmentId); + } + + [Test] + public void OnTraceRemoved_FiresWhenTraceRemoved() + { + int capturedSegmentId = -1; + var net = _board.CreateNet("VIN"); + var trace = _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(0, 3)); + _board.OnTraceRemoved += id => capturedSegmentId = id; + + _board.RemoveTrace(trace.SegmentId); + + Assert.AreEqual(trace.SegmentId, capturedSegmentId); + } + + [Test] + public void OnTraceRemoved_DoesNotFire_ForNonExistentSegmentId() + { + bool fired = false; + _board.OnTraceRemoved += _ => fired = true; + + _board.RemoveTrace(9999); + + Assert.IsFalse(fired); + } + + [Test] + public void OnPinsConnected_FiresWhenSecondPinConnectedToNet() + { + int capturedNetId = -1; + _board.OnPinsConnected += (netId, p1, p2) => capturedNetId = netId; + + var comp1 = PlaceComponentAt(0, 0); + var comp2 = PlaceComponentAt(5, 5); + var net = _board.CreateNet("VIN"); + + // First pin — no event yet + _board.ConnectPinToNet(net.NetId, new PinReference(comp1.InstanceId, 0, comp1.GetPinWorldPosition(0))); + Assert.AreEqual(-1, capturedNetId, "OnPinsConnected should not fire for first pin"); + + // Second pin — event fires + _board.ConnectPinToNet(net.NetId, new PinReference(comp2.InstanceId, 0, comp2.GetPinWorldPosition(0))); + Assert.AreEqual(net.NetId, capturedNetId, "OnPinsConnected should fire when second pin connects"); + } + + #endregion + + #region ID Auto-Increment Isolation Tests + + [Test] + public void InstanceIds_AreUnique_AcrossMultiplePlacements() + { + var ids = new HashSet(); + for (int i = 0; i < 5; i++) + ids.Add(PlaceComponentAt(i * 2, 0).InstanceId); + + Assert.AreEqual(5, ids.Count, "All instance IDs must be unique"); + } + + [Test] + public void NetIds_AreUnique_AcrossMultipleCreateNetCalls() + { + var ids = new HashSet(); + for (int i = 0; i < 5; i++) + ids.Add(_board.CreateNet($"NET{i}").NetId); + + Assert.AreEqual(5, ids.Count, "All net IDs must be unique"); + } + + [Test] + public void SegmentIds_AreUnique_AcrossMultipleAddTraceCalls() + { + var net = _board.CreateNet("VIN"); + var ids = new HashSet(); + for (int i = 0; i < 5; i++) + ids.Add(_board.AddTrace(net.NetId, new GridPosition(0, i), new GridPosition(3, i)).SegmentId); + + Assert.AreEqual(5, ids.Count, "All segment IDs must be unique"); + } + + #endregion + + #region Integration / Scenario Tests + + [Test] + public void FullVoltageDividerTopology_IsTrackedCorrectly() + { + // Simulates: VIN -> R1 -> VOUT -> R2 -> GND + var board = new BoardState(20, 10); + var netVin = board.CreateNet("VIN"); + var netGnd = board.CreateNet("GND"); + var netVout = board.CreateNet("VOUT"); + + var vSourcePins = new[] + { + new PinInstance(0, "positive", new GridPosition(0, 0)), + new PinInstance(1, "negative", new GridPosition(0, 1)) + }; + var vsource = board.PlaceComponent("vsource_5v", new GridPosition(0, 0), 0, vSourcePins); + board.ConnectPinToNet(netVin.NetId, new PinReference(vsource.InstanceId, 0, vsource.GetPinWorldPosition(0))); + board.ConnectPinToNet(netGnd.NetId, new PinReference(vsource.InstanceId, 1, vsource.GetPinWorldPosition(1))); + + var r1Pins = new[] + { + new PinInstance(0, "t1", new GridPosition(0, 0)), + new PinInstance(1, "t2", new GridPosition(1, 0)) + }; + var r1 = board.PlaceComponent("resistor_1k", new GridPosition(3, 0), 0, r1Pins); + board.ConnectPinToNet(netVin.NetId, new PinReference(r1.InstanceId, 0, r1.GetPinWorldPosition(0))); + board.ConnectPinToNet(netVout.NetId, new PinReference(r1.InstanceId, 1, r1.GetPinWorldPosition(1))); + + var r2Pins = new[] + { + new PinInstance(0, "t1", new GridPosition(0, 0)), + new PinInstance(1, "t2", new GridPosition(1, 0)) + }; + var r2 = board.PlaceComponent("resistor_2k", new GridPosition(6, 0), 0, r2Pins); + board.ConnectPinToNet(netVout.NetId, new PinReference(r2.InstanceId, 0, r2.GetPinWorldPosition(0))); + board.ConnectPinToNet(netGnd.NetId, new PinReference(r2.InstanceId, 1, r2.GetPinWorldPosition(1))); + + Assert.AreEqual(3, board.Components.Count, "Should have 3 components"); + Assert.AreEqual(3, board.Nets.Count, "Should have 3 nets"); + Assert.AreEqual(2, netVin.ConnectedPins.Count, "VIN net should have 2 pins (vsource+ and r1t1)"); + Assert.AreEqual(2, netGnd.ConnectedPins.Count, "GND net should have 2 pins"); + Assert.AreEqual(2, netVout.ConnectedPins.Count, "VOUT net should have 2 pins"); + } + + [Test] + public void PlaceThenRemoveComponent_LeavesNoArtifacts() + { + var comp = PlaceComponentAt(5, 5); + _board.RemoveComponent(comp.InstanceId); + + Assert.AreEqual(0, _board.Components.Count); + Assert.IsNull(_board.GetComponent(comp.InstanceId)); + Assert.IsNull(_board.GetComponentAt(new GridPosition(5, 5))); + Assert.IsFalse(_board.IsPositionOccupied(new GridPosition(5, 5))); + } + + [Test] + public void AddThenRemoveTrace_NetSurvives_WhenStillHasPinsConnected() + { + var comp1 = PlaceComponentAt(0, 0); + var comp2 = PlaceComponentAt(5, 5); + var net = _board.CreateNet("NET1"); + _board.ConnectPinToNet(net.NetId, new PinReference(comp1.InstanceId, 0, comp1.GetPinWorldPosition(0))); + _board.ConnectPinToNet(net.NetId, new PinReference(comp2.InstanceId, 0, comp2.GetPinWorldPosition(0))); + + var trace = _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + _board.RemoveTrace(trace.SegmentId); + + // Net has pins connected (comp1 and comp2), so it should not be deleted + // BUT: current impl clears pins and deletes net when last trace is removed + // This characterization test captures the ACTUAL behavior: + Assert.IsNull(_board.GetNet(net.NetId), + "CHARACTERIZATION: Current implementation deletes net and clears pins when last trace removed, even if pins were connected"); + } + + [Test] + public void ToString_ReturnsNonEmptyString() + { + var str = _board.ToString(); + + Assert.IsNotNull(str); + Assert.IsNotEmpty(str); + } + + #endregion + } +} diff --git a/Assets/10_Scripts/30_Tests/10_Core/BoardStateTests.cs.meta b/Assets/10_Scripts/30_Tests/10_Core/BoardStateTests.cs.meta new file mode 100644 index 0000000..3088a4b --- /dev/null +++ b/Assets/10_Scripts/30_Tests/10_Core/BoardStateTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 902e37e7fd317f748be6fdb229e6b7c4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/10_Core/DRCCheckerTests.cs b/Assets/10_Scripts/30_Tests/10_Core/DRCCheckerTests.cs new file mode 100644 index 0000000..a09d0a4 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/10_Core/DRCCheckerTests.cs @@ -0,0 +1,199 @@ +using NUnit.Framework; +using System.Collections.Generic; +using System.Linq; +using CircuitCraft.Core; + +namespace CircuitCraft.Tests.Core +{ + [TestFixture] + public class DRCCheckerTests + { + private DRCChecker _checker; + + [SetUp] + public void SetUp() + { + _checker = new DRCChecker(); + } + + // ── helpers ───────────────────────────────────────────────────────────── + + private static List CreatePins(int count) + { + var pins = new List(); + for (int i = 0; i < count; i++) + pins.Add(new PinInstance(i, $"pin{i}", new GridPosition(i, 0))); + return pins; + } + + // ── Test 1: null board throws ArgumentNullException ────────────────────── + + [Test] + public void Check_NullBoard_ThrowsArgumentNullException() + { + Assert.Throws(() => _checker.Check(null)); + } + + // ── Test 2: empty board → no violations ────────────────────────────────── + + [Test] + public void Check_EmptyBoard_ReturnsNoViolations() + { + var board = new BoardState(10, 10); + + var result = _checker.Check(board); + + Assert.IsFalse(result.HasViolations); + Assert.AreEqual(0, result.ShortCount); + Assert.AreEqual(0, result.UnconnectedCount); + Assert.AreEqual(0, result.Violations.Count); + } + + // ── Test 3: all pins connected → no unconnected pin violations ─────────── + + [Test] + public void Check_AllPinsConnected_ReturnsNoUnconnectedPins() + { + var board = new BoardState(10, 10); + var net = board.CreateNet("NET1"); + + var pins = CreatePins(2); + var comp = board.PlaceComponent("resistor", new GridPosition(0, 0), 0, pins); + + board.ConnectPinToNet(net.NetId, + new PinReference(comp.InstanceId, 0, comp.GetPinWorldPosition(0))); + board.ConnectPinToNet(net.NetId, + new PinReference(comp.InstanceId, 1, comp.GetPinWorldPosition(1))); + + var result = _checker.Check(board); + + Assert.AreEqual(0, result.UnconnectedCount); + } + + // ── Test 4: unconnected pins are detected ──────────────────────────────── + + [Test] + public void Check_UnconnectedPins_DetectsAllPins() + { + var board = new BoardState(10, 10); + + var pins = CreatePins(2); + board.PlaceComponent("resistor", new GridPosition(0, 0), 0, pins); + + var result = _checker.Check(board); + + Assert.IsTrue(result.HasViolations); + Assert.AreEqual(2, result.UnconnectedCount); + Assert.IsTrue(result.Violations.All(v => v.ViolationType == DRCViolationType.UnconnectedPin)); + } + + // ── Test 5: traces from different nets overlapping → short detected ─────── + + [Test] + public void Check_OverlappingTracesFromDifferentNets_DetectsShort() + { + var board = new BoardState(20, 20); + var net1 = board.CreateNet("NET1"); + var net2 = board.CreateNet("NET2"); + + // net1 trace: horizontal from (0,0) to (5,0) + board.AddTrace(net1.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + // net2 trace: vertical from (3,0) to (3,5) — overlaps at (3,0) + board.AddTrace(net2.NetId, new GridPosition(3, 0), new GridPosition(3, 5)); + + var result = _checker.Check(board); + + Assert.IsTrue(result.HasViolations); + Assert.GreaterOrEqual(result.ShortCount, 1); + + var shortViolations = result.Violations + .Where(v => v.ViolationType == DRCViolationType.Short) + .ToList(); + Assert.IsTrue(shortViolations.Any(v => v.Location.Equals(new GridPosition(3, 0))), + "Expected a Short violation at (3,0)"); + } + + // ── Test 6: traces from same net overlapping → no short ────────────────── + + [Test] + public void Check_OverlappingTracesFromSameNet_NoShort() + { + var board = new BoardState(20, 20); + var net = board.CreateNet("NET1"); + + // Two traces on the same net that share positions + board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + board.AddTrace(net.NetId, new GridPosition(3, 0), new GridPosition(8, 0)); + + var result = _checker.Check(board); + + Assert.AreEqual(0, result.ShortCount); + } + + // ── Test 7: traces from different nets that do NOT overlap → no short ───── + + [Test] + public void Check_NonOverlappingTracesFromDifferentNets_NoShort() + { + var board = new BoardState(20, 20); + var net1 = board.CreateNet("NET1"); + var net2 = board.CreateNet("NET2"); + + // net1: (0,0)→(2,0) + board.AddTrace(net1.NetId, new GridPosition(0, 0), new GridPosition(2, 0)); + // net2: (5,0)→(7,0) — separated, no overlap + board.AddTrace(net2.NetId, new GridPosition(5, 0), new GridPosition(7, 0)); + + var result = _checker.Check(board); + + Assert.AreEqual(0, result.ShortCount); + } + + // ── Test 8: mixed shorts AND unconnected pins ───────────────────────────── + + [Test] + public void Check_MixedShortsAndUnconnectedPins_DetectsBoth() + { + var board = new BoardState(20, 20); + var net1 = board.CreateNet("NET1"); + var net2 = board.CreateNet("NET2"); + + // Short: two crossing nets + board.AddTrace(net1.NetId, new GridPosition(0, 3), new GridPosition(5, 3)); + board.AddTrace(net2.NetId, new GridPosition(2, 0), new GridPosition(2, 5)); + + // Unconnected pins: component with 2 pins not wired to anything + var pins = CreatePins(2); + board.PlaceComponent("resistor", new GridPosition(10, 10), 0, pins); + + var result = _checker.Check(board); + + Assert.IsTrue(result.HasViolations); + Assert.GreaterOrEqual(result.ShortCount, 1); + Assert.AreEqual(2, result.UnconnectedCount); + } + + // ── Test 9: component with some pins connected, some not ───────────────── + + [Test] + public void Check_PartiallyConnectedComponent_DetectsOnlyUnconnectedPins() + { + var board = new BoardState(10, 10); + var net = board.CreateNet("NET1"); + + var pins = CreatePins(3); + var comp = board.PlaceComponent("transistor", new GridPosition(0, 0), 0, pins); + + // Only connect pin 0 + board.ConnectPinToNet(net.NetId, + new PinReference(comp.InstanceId, 0, comp.GetPinWorldPosition(0))); + + var result = _checker.Check(board); + + Assert.IsTrue(result.HasViolations); + Assert.AreEqual(2, result.UnconnectedCount, + "Pins 1 and 2 are unconnected; only pin 0 was wired."); + Assert.AreEqual(0, result.ShortCount); + } + } +} diff --git a/Assets/10_Scripts/30_Tests/10_Core/DRCCheckerTests.cs.meta b/Assets/10_Scripts/30_Tests/10_Core/DRCCheckerTests.cs.meta new file mode 100644 index 0000000..e43f4df --- /dev/null +++ b/Assets/10_Scripts/30_Tests/10_Core/DRCCheckerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6337c6b54015df749b543f2350c38b1b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/10_Core/ObjectiveEvaluatorTests.cs b/Assets/10_Scripts/30_Tests/10_Core/ObjectiveEvaluatorTests.cs new file mode 100644 index 0000000..759e078 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/10_Core/ObjectiveEvaluatorTests.cs @@ -0,0 +1,591 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +using CircuitCraft.Core; +using CircuitCraft.Simulation; + +namespace CircuitCraft.Tests.Core +{ + [TestFixture] + public class ObjectiveEvaluatorTests + { + private ObjectiveEvaluator _evaluator; + + [SetUp] + public void SetUp() + { + _evaluator = new ObjectiveEvaluator(); + } + + // ------------------------------------------------------------------ // + // Helpers — no tuple syntax for LSP compatibility + // ------------------------------------------------------------------ // + + /// Builds a successful SimulationResult with no probes. + private static SimulationResult MakeSuccessResult() + { + return SimulationResult.Success(SimulationType.DCOperatingPoint, 1.0); + } + + /// Builds a successful SimulationResult with one voltage probe. + private static SimulationResult MakeSuccessResult(string node, double voltage) + { + var result = SimulationResult.Success(SimulationType.DCOperatingPoint, 1.0); + result.ProbeResults.Add(new ProbeResult(node, ProbeType.Voltage, node, voltage)); + return result; + } + + /// Builds a successful SimulationResult with two voltage probes. + private static SimulationResult MakeSuccessResult( + string node1, double voltage1, + string node2, double voltage2) + { + var result = SimulationResult.Success(SimulationType.DCOperatingPoint, 1.0); + result.ProbeResults.Add(new ProbeResult(node1, ProbeType.Voltage, node1, voltage1)); + result.ProbeResults.Add(new ProbeResult(node2, ProbeType.Voltage, node2, voltage2)); + return result; + } + + /// Builds a successful SimulationResult with three voltage probes. + private static SimulationResult MakeSuccessResult( + string node1, double voltage1, + string node2, double voltage2, + string node3, double voltage3) + { + var result = SimulationResult.Success(SimulationType.DCOperatingPoint, 1.0); + result.ProbeResults.Add(new ProbeResult(node1, ProbeType.Voltage, node1, voltage1)); + result.ProbeResults.Add(new ProbeResult(node2, ProbeType.Voltage, node2, voltage2)); + result.ProbeResults.Add(new ProbeResult(node3, ProbeType.Voltage, node3, voltage3)); + return result; + } + + /// Builds a failed SimulationResult. + private static SimulationResult MakeFailureResult(string message) + { + return SimulationResult.Failure( + SimulationType.DCOperatingPoint, + SimulationStatus.ConvergenceFailure, + message); + } + + private static SimulationResult MakeFailureResult() + { + return MakeFailureResult("Convergence failure"); + } + + // ------------------------------------------------------------------ // + // Null guard tests + // ------------------------------------------------------------------ // + + [Test] + public void Evaluate_NullSimResult_ThrowsArgumentNullException() + { + var testCases = new TestCaseInput[0]; + + Assert.Throws(() => + _evaluator.Evaluate(null, testCases)); + } + + [Test] + public void Evaluate_NullTestCases_ThrowsArgumentNullException() + { + var simResult = MakeSuccessResult(); + + Assert.Throws(() => + _evaluator.Evaluate(simResult, null)); + } + + // ------------------------------------------------------------------ // + // Simulation-failed path + // ------------------------------------------------------------------ // + + [Test] + public void Evaluate_SimulationFailed_ReturnsFailed() + { + var simResult = MakeFailureResult("No convergence"); + var testCases = new[] + { + new TestCaseInput("nodeA", 5.0, 0.01) + }; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.IsFalse(result.Passed, "Evaluation must fail when simulation failed"); + } + + [Test] + public void Evaluate_SimulationFailed_ReturnsEmptyResults() + { + var simResult = MakeFailureResult(); + var testCases = new[] + { + new TestCaseInput("nodeA", 5.0, 0.01) + }; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.AreEqual(0, result.Results.Count, + "SimulationFailed path should return empty Results list"); + } + + [Test] + public void Evaluate_SimulationFailed_SummaryContainsReason() + { + var simResult = MakeFailureResult("Timed out after 5s"); + var testCases = new TestCaseInput[0]; + + var result = _evaluator.Evaluate(simResult, testCases); + + StringAssert.Contains("Timed out after 5s", result.Summary, + "Summary should include the failure reason from StatusMessage"); + } + + [Test] + public void Evaluate_SimulationFailed_SummaryContainsEvaluationFailed() + { + var simResult = MakeFailureResult("any error"); + var testCases = new TestCaseInput[0]; + + var result = _evaluator.Evaluate(simResult, testCases); + + StringAssert.Contains("Evaluation failed", result.Summary); + } + + // ------------------------------------------------------------------ // + // Empty test cases + // ------------------------------------------------------------------ // + + [Test] + public void Evaluate_EmptyTestCases_ReturnsPassedTrue() + { + var simResult = MakeSuccessResult("nodeA", 5.0); + var testCases = new TestCaseInput[0]; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.IsTrue(result.Passed, + "With no test cases, allPassed starts true and stays true"); + } + + [Test] + public void Evaluate_EmptyTestCases_ReturnsEmptyResults() + { + var simResult = MakeSuccessResult(); + var testCases = new TestCaseInput[0]; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.AreEqual(0, result.Results.Count); + } + + [Test] + public void Evaluate_EmptyTestCases_SummaryContainsPassed() + { + var simResult = MakeSuccessResult(); + var testCases = new TestCaseInput[0]; + + var result = _evaluator.Evaluate(simResult, testCases); + + StringAssert.Contains("PASSED", result.Summary); + } + + // ------------------------------------------------------------------ // + // All test cases pass + // ------------------------------------------------------------------ // + + [Test] + public void Evaluate_AllTestCasesPass_ReturnsPassed() + { + var simResult = MakeSuccessResult("out", 5.0, "vcc", 9.0); + var testCases = new[] + { + new TestCaseInput("out", 5.0, 0.01), + new TestCaseInput("vcc", 9.0, 0.01) + }; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.IsTrue(result.Passed, "All passing test cases should yield Passed=true"); + } + + [Test] + public void Evaluate_AllTestCasesPass_AllResultsArePassed() + { + var simResult = MakeSuccessResult("out", 5.0, "vcc", 9.0); + var testCases = new[] + { + new TestCaseInput("out", 5.0, 0.01), + new TestCaseInput("vcc", 9.0, 0.01) + }; + + var result = _evaluator.Evaluate(simResult, testCases); + + foreach (var r in result.Results) + { + Assert.IsTrue(r.Passed, "Test case '" + r.TestName + "' should have passed"); + } + } + + [Test] + public void Evaluate_AllTestCasesPass_SummaryContainsPassed() + { + var simResult = MakeSuccessResult("out", 5.0); + var testCases = new[] { new TestCaseInput("out", 5.0, 0.01) }; + + var result = _evaluator.Evaluate(simResult, testCases); + + StringAssert.Contains("PASSED", result.Summary); + } + + [Test] + public void Evaluate_AllTestCasesPass_ResultCountMatchesTestCases() + { + var simResult = MakeSuccessResult("a", 1.0, "b", 2.0, "c", 3.0); + var testCases = new[] + { + new TestCaseInput("a", 1.0, 0.01), + new TestCaseInput("b", 2.0, 0.01), + new TestCaseInput("c", 3.0, 0.01) + }; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.AreEqual(3, result.Results.Count); + } + + // ------------------------------------------------------------------ // + // Some test cases fail + // ------------------------------------------------------------------ // + + [Test] + public void Evaluate_OneTestCaseFails_ReturnsFailed() + { + var simResult = MakeSuccessResult("out", 3.0); // expected 5.0 + var testCases = new[] + { + new TestCaseInput("out", 5.0, 0.01) + }; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.IsFalse(result.Passed, "Any failing test case should set Passed=false"); + } + + [Test] + public void Evaluate_OneTestCaseFails_FailedResultIsMarkedFailed() + { + var simResult = MakeSuccessResult("out", 3.0); + var testCases = new[] + { + new TestCaseInput("out", 5.0, 0.01) + }; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.IsFalse(result.Results[0].Passed); + } + + [Test] + public void Evaluate_MixedResults_PassedIsFalseWhenAnyFail() + { + var simResult = MakeSuccessResult("nodeA", 5.0, "nodeB", 2.0); // nodeB expected 9.0 + var testCases = new[] + { + new TestCaseInput("nodeA", 5.0, 0.01), // pass + new TestCaseInput("nodeB", 9.0, 0.01) // fail + }; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.IsFalse(result.Passed); + Assert.IsTrue(result.Results[0].Passed, "nodeA should pass"); + Assert.IsFalse(result.Results[1].Passed, "nodeB should fail"); + } + + [Test] + public void Evaluate_SomeFail_SummaryContainsFailed() + { + var simResult = MakeSuccessResult("out", 0.0); + var testCases = new[] { new TestCaseInput("out", 5.0, 0.01) }; + + var result = _evaluator.Evaluate(simResult, testCases); + + StringAssert.Contains("FAILED", result.Summary); + } + + [Test] + public void Evaluate_SomeFail_SummaryContainsPassRatio() + { + // 1 of 2 pass + var simResult = MakeSuccessResult("a", 1.0, "b", 99.0); + var testCases = new[] + { + new TestCaseInput("a", 1.0, 0.01), // pass + new TestCaseInput("b", 5.0, 0.01) // fail + }; + + var result = _evaluator.Evaluate(simResult, testCases); + + StringAssert.Contains("1/2", result.Summary, + "Summary should contain pass ratio e.g. '1/2 test cases'"); + } + + // ------------------------------------------------------------------ // + // Missing node (voltage not found in sim result) + // ------------------------------------------------------------------ // + + [Test] + public void Evaluate_NodeMissingFromSimResult_TestCaseFails() + { + var simResult = MakeSuccessResult(); // no probes for "missing_node" + var testCases = new[] + { + new TestCaseInput("missing_node", 5.0, 0.01) + }; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.IsFalse(result.Passed); + Assert.IsFalse(result.Results[0].Passed); + } + + [Test] + public void Evaluate_NodeMissingFromSimResult_ActualValueIsNaN() + { + var simResult = MakeSuccessResult(); + var testCases = new[] + { + new TestCaseInput("ghost_node", 5.0, 0.01) + }; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.IsTrue(double.IsNaN(result.Results[0].ActualValue), + "Missing node should produce NaN as actual value"); + } + + [Test] + public void Evaluate_NodeMissingFromSimResult_MessageContainsNodeName() + { + var simResult = MakeSuccessResult(); + var testCases = new[] + { + new TestCaseInput("node_xyz", 5.0, 0.01) + }; + + var result = _evaluator.Evaluate(simResult, testCases); + + StringAssert.Contains("node_xyz", result.Results[0].Message, + "Failure message should name the missing node"); + } + + // ------------------------------------------------------------------ // + // Tolerance boundary tests + // ------------------------------------------------------------------ // + + [Test] + public void Evaluate_ValueExactlyAtExpected_Passes() + { + var simResult = MakeSuccessResult("vout", 5.0); + var testCases = new[] { new TestCaseInput("vout", 5.0, 0.001) }; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.IsTrue(result.Results[0].Passed, + "Exact match should always pass"); + } + + [Test] + public void Evaluate_ValueExactlyAtPositiveTolerance_Passes() + { + // actual = expected + tolerance exactly -> difference == tolerance -> passed (<=) + var simResult = MakeSuccessResult("vout", 5.1); + var testCases = new[] { new TestCaseInput("vout", 5.0, 0.1) }; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.IsTrue(result.Results[0].Passed, + "Value at exactly +tolerance boundary should pass (<=)"); + } + + [Test] + public void Evaluate_ValueExactlyAtNegativeTolerance_Passes() + { + // actual = expected - tolerance exactly -> passes + var simResult = MakeSuccessResult("vout", 4.9); + var testCases = new[] { new TestCaseInput("vout", 5.0, 0.1) }; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.IsTrue(result.Results[0].Passed, + "Value at exactly -tolerance boundary should pass (<=)"); + } + + [Test] + public void Evaluate_ValueJustBeyondPositiveTolerance_Fails() + { + // actual = expected + tolerance + epsilon -> fails + var simResult = MakeSuccessResult("vout", 5.101); + var testCases = new[] { new TestCaseInput("vout", 5.0, 0.1) }; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.IsFalse(result.Results[0].Passed, + "Value beyond +tolerance boundary should fail"); + } + + [Test] + public void Evaluate_ValueJustBeyondNegativeTolerance_Fails() + { + var simResult = MakeSuccessResult("vout", 4.899); + var testCases = new[] { new TestCaseInput("vout", 5.0, 0.1) }; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.IsFalse(result.Results[0].Passed, + "Value beyond -tolerance boundary should fail"); + } + + [Test] + public void Evaluate_ZeroTolerance_ExactMatchPasses() + { + var simResult = MakeSuccessResult("vout", 5.0); + var testCases = new[] { new TestCaseInput("vout", 5.0, 0.0) }; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.IsTrue(result.Results[0].Passed, + "Zero tolerance with exact match should pass"); + } + + [Test] + public void Evaluate_ZeroTolerance_AnyDifferenceFails() + { + var simResult = MakeSuccessResult("vout", 5.0001); + var testCases = new[] { new TestCaseInput("vout", 5.0, 0.0) }; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.IsFalse(result.Results[0].Passed, + "Zero tolerance: any difference should fail"); + } + + // ------------------------------------------------------------------ // + // Result DTO correctness + // ------------------------------------------------------------------ // + + [Test] + public void Evaluate_PassingTestCase_ResultCarriesCorrectValues() + { + var simResult = MakeSuccessResult("vout", 4.95); + var testCases = new[] { new TestCaseInput("vout", 5.0, 0.1) }; + + var result = _evaluator.Evaluate(simResult, testCases); + + var r = result.Results[0]; + Assert.AreEqual("vout", r.TestName); + Assert.AreEqual(5.0, r.ExpectedValue, 1e-9); + Assert.AreEqual(4.95, r.ActualValue, 1e-9); + Assert.AreEqual(0.1, r.Tolerance, 1e-9); + Assert.IsTrue(r.Passed); + } + + [Test] + public void Evaluate_FailingTestCase_MessageContainsFAIL() + { + var simResult = MakeSuccessResult("vout", 3.0); + var testCases = new[] { new TestCaseInput("vout", 5.0, 0.01) }; + + var result = _evaluator.Evaluate(simResult, testCases); + + StringAssert.Contains("FAIL", result.Results[0].Message); + } + + [Test] + public void Evaluate_PassingTestCase_MessageContainsPASS() + { + var simResult = MakeSuccessResult("vout", 5.0); + var testCases = new[] { new TestCaseInput("vout", 5.0, 0.01) }; + + var result = _evaluator.Evaluate(simResult, testCases); + + StringAssert.Contains("PASS", result.Results[0].Message); + } + + [Test] + public void Evaluate_FailingTestCase_MessageContainsDifferenceValue() + { + // actual=3.0, expected=5.0, difference=2.0 + var simResult = MakeSuccessResult("vout", 3.0); + var testCases = new[] { new TestCaseInput("vout", 5.0, 0.01) }; + + var result = _evaluator.Evaluate(simResult, testCases); + + StringAssert.Contains("off by", result.Results[0].Message, + "Failure message should include 'off by '"); + } + + // ------------------------------------------------------------------ // + // CompletedWithWarnings is treated as success + // ------------------------------------------------------------------ // + + [Test] + public void Evaluate_SimulationCompletedWithWarnings_IsEvaluated() + { + // CompletedWithWarnings -> IsSuccess=true -> should be evaluated normally + var simResult = new SimulationResult + { + HasRun = true, + Status = SimulationStatus.CompletedWithWarnings, + StatusMessage = "Completed with minor warnings", + SimulationType = SimulationType.DCOperatingPoint + }; + simResult.ProbeResults.Add(new ProbeResult("vout", ProbeType.Voltage, "vout", 5.0)); + + var testCases = new[] { new TestCaseInput("vout", 5.0, 0.01) }; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.IsTrue(result.Passed, + "CompletedWithWarnings is treated as IsSuccess=true and should be evaluated normally"); + } + + // ------------------------------------------------------------------ // + // Multiple test cases — order preservation + // ------------------------------------------------------------------ // + + [Test] + public void Evaluate_MultipleTestCases_ResultOrderPreserved() + { + var simResult = MakeSuccessResult("a", 1.0, "b", 2.0, "c", 3.0); + var testCases = new[] + { + new TestCaseInput("a", 1.0, 0.01), + new TestCaseInput("b", 2.0, 0.01), + new TestCaseInput("c", 3.0, 0.01) + }; + + var result = _evaluator.Evaluate(simResult, testCases); + + Assert.AreEqual("a", result.Results[0].TestName); + Assert.AreEqual("b", result.Results[1].TestName); + Assert.AreEqual("c", result.Results[2].TestName); + } + + [Test] + public void Evaluate_AllPassed_SummaryContainsFullPassRatio() + { + var simResult = MakeSuccessResult("a", 1.0, "b", 2.0); + var testCases = new[] + { + new TestCaseInput("a", 1.0, 0.01), + new TestCaseInput("b", 2.0, 0.01) + }; + + var result = _evaluator.Evaluate(simResult, testCases); + + StringAssert.Contains("2/2", result.Summary, + "Summary should show full pass count e.g. '2/2 test cases'"); + } + } +} diff --git a/Assets/10_Scripts/30_Tests/10_Core/ObjectiveEvaluatorTests.cs.meta b/Assets/10_Scripts/30_Tests/10_Core/ObjectiveEvaluatorTests.cs.meta new file mode 100644 index 0000000..c39123d --- /dev/null +++ b/Assets/10_Scripts/30_Tests/10_Core/ObjectiveEvaluatorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 93e816d78ba28894680fc2ac1e88d2b3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/10_Core/ScoringSystemTests.cs b/Assets/10_Scripts/30_Tests/10_Core/ScoringSystemTests.cs new file mode 100644 index 0000000..2f24a75 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/10_Core/ScoringSystemTests.cs @@ -0,0 +1,473 @@ +using System.Collections.Generic; +using NUnit.Framework; +using CircuitCraft.Core; + +namespace CircuitCraft.Tests.Core +{ + [TestFixture] + public class ScoringSystemTests + { + private ScoringSystem _scoringSystem; + + [SetUp] + public void SetUp() + { + _scoringSystem = new ScoringSystem(); + } + + // ------------------------------------------------------------------ // + // Failed circuit + // ------------------------------------------------------------------ // + + [Test] + public void Calculate_FailedCircuit_Returns0StarsAnd0Score() + { + var input = new ScoringInput( + circuitPassed: false, + totalComponentCost: 50f, + budgetLimit: 100f, + boardArea: 5, + targetArea: 10, + traceCount: 0); + + var result = _scoringSystem.Calculate(input); + + Assert.IsFalse(result.Passed, "Passed should be false when circuit fails"); + Assert.AreEqual(0, result.Stars, "Failed circuit should yield 0 stars"); + Assert.AreEqual(0, result.TotalScore, "Failed circuit should yield 0 total score"); + Assert.AreEqual(0, result.BaseScore, "Failed circuit should yield 0 base score"); + Assert.AreEqual(0, result.BudgetBonus, "Failed circuit should yield 0 budget bonus"); + Assert.AreEqual(0, result.AreaBonus, "Failed circuit should yield 0 area bonus"); + } + + [Test] + public void Calculate_FailedCircuit_SummaryContainsFailedText() + { + var input = new ScoringInput(false, 0f, 0f, 1, 1, 0); + + var result = _scoringSystem.Calculate(input); + + StringAssert.Contains("FAILED", result.Summary, + "Summary should contain FAILED for a failed circuit"); + } + + [Test] + public void Calculate_FailedCircuit_LineItemContainsCircuitFailed() + { + var input = new ScoringInput(false, 0f, 0f, 1, 1, 0); + + var result = _scoringSystem.Calculate(input); + + Assert.Greater(result.LineItems.Count, 0, "LineItems should not be empty"); + bool found = false; + foreach (var item in result.LineItems) + { + if (item.Label.Contains("Circuit Failed")) + { + found = true; + break; + } + } + Assert.IsTrue(found, "LineItems should contain a 'Circuit Failed' entry"); + } + + // ------------------------------------------------------------------ // + // Passed circuit — all bonuses earned (3 stars) + // ------------------------------------------------------------------ // + + [Test] + public void Calculate_PassedUnderBudgetWithinArea_Returns3Stars() + { + var input = new ScoringInput( + circuitPassed: true, + totalComponentCost: 50f, + budgetLimit: 100f, + boardArea: 5, + targetArea: 10, + traceCount: 0); + + var result = _scoringSystem.Calculate(input); + + Assert.IsTrue(result.Passed, "Passed should be true"); + Assert.AreEqual(3, result.Stars, "Should be 3 stars with all bonuses"); + } + + [Test] + public void Calculate_PassedUnderBudgetWithinArea_BaseScoreIs1000() + { + var input = new ScoringInput(true, 50f, 100f, 5, 10, 0); + + var result = _scoringSystem.Calculate(input); + + Assert.AreEqual(1000, result.BaseScore, "Base score should be 1000 on pass"); + } + + [Test] + public void Calculate_PassedUnderBudgetWithinArea_BudgetBonusIs500() + { + var input = new ScoringInput(true, 50f, 100f, 5, 10, 0); + + var result = _scoringSystem.Calculate(input); + + Assert.AreEqual(500, result.BudgetBonus, "Budget bonus should be 500 when under budget"); + } + + [Test] + public void Calculate_PassedUnderBudgetWithinArea_AreaBonusIsPositive() + { + // boardArea=5, targetArea=10 → ratio=0.5 → factor=min(1, 2-0.5)=1.0 → areaBonus=300 + var input = new ScoringInput(true, 50f, 100f, 5, 10, 0); + + var result = _scoringSystem.Calculate(input); + + Assert.Greater(result.AreaBonus, 0, "Area bonus should be positive when within target"); + } + + [Test] + public void Calculate_PassedUnderBudgetWithinArea_TotalScoreIs1800() + { + // boardArea=5, targetArea=10 → ratio=0.5 → areaFactor=1.0 → areaBonus=300 + // total = 1000 + 500 + 300 = 1800 + var input = new ScoringInput(true, 50f, 100f, 5, 10, 0); + + var result = _scoringSystem.Calculate(input); + + Assert.AreEqual(1800, result.TotalScore, "Total score should be 1800 for full 3-star run"); + } + + // ------------------------------------------------------------------ // + // Passed + over budget + within area (2 stars) + // ------------------------------------------------------------------ // + + [Test] + public void Calculate_PassedOverBudgetWithinArea_Returns2Stars() + { + var input = new ScoringInput( + circuitPassed: true, + totalComponentCost: 150f, + budgetLimit: 100f, + boardArea: 5, + targetArea: 10, + traceCount: 0); + + var result = _scoringSystem.Calculate(input); + + Assert.AreEqual(2, result.Stars, "Over-budget pass should yield 2 stars"); + } + + [Test] + public void Calculate_PassedOverBudgetWithinArea_BudgetBonusIs0() + { + var input = new ScoringInput(true, 150f, 100f, 5, 10, 0); + + var result = _scoringSystem.Calculate(input); + + Assert.AreEqual(0, result.BudgetBonus, "Budget bonus should be 0 when over budget"); + } + + // ------------------------------------------------------------------ // + // Passed + under budget + over area (2 stars) + // ------------------------------------------------------------------ // + + [Test] + public void Calculate_PassedUnderBudgetOverArea_Returns2Stars() + { + var input = new ScoringInput( + circuitPassed: true, + totalComponentCost: 50f, + budgetLimit: 100f, + boardArea: 20, + targetArea: 10, + traceCount: 0); + + var result = _scoringSystem.Calculate(input); + + Assert.AreEqual(2, result.Stars, "Over-area pass should yield 2 stars"); + } + + [Test] + public void Calculate_PassedUnderBudgetOverArea_AreaBonusIs0() + { + // boardArea=20, targetArea=10 → ratio=2.0 → factor=max(0, 2-2.0)=0 → areaBonus=0 + var input = new ScoringInput(true, 50f, 100f, 20, 10, 0); + + var result = _scoringSystem.Calculate(input); + + Assert.AreEqual(0, result.AreaBonus, "Area bonus should be 0 when twice the target"); + } + + // ------------------------------------------------------------------ // + // Passed + over budget + over area (1 star) + // ------------------------------------------------------------------ // + + [Test] + public void Calculate_PassedOverBudgetOverArea_Returns1Star() + { + var input = new ScoringInput( + circuitPassed: true, + totalComponentCost: 150f, + budgetLimit: 100f, + boardArea: 20, + targetArea: 10, + traceCount: 0); + + var result = _scoringSystem.Calculate(input); + + Assert.AreEqual(1, result.Stars, "No bonus pass should yield 1 star"); + } + + [Test] + public void Calculate_PassedOverBudgetOverArea_TotalScoreIs1000() + { + var input = new ScoringInput(true, 150f, 100f, 20, 10, 0); + + var result = _scoringSystem.Calculate(input); + + Assert.AreEqual(1000, result.TotalScore, "Total score should be 1000 (base only) when no bonuses"); + } + + // ------------------------------------------------------------------ // + // No budget limit (auto-pass budget) + // ------------------------------------------------------------------ // + + [Test] + public void Calculate_NoBudgetLimit_BudgetBonusEarned() + { + // budgetLimit=0 → auto-pass regardless of cost + var input = new ScoringInput( + circuitPassed: true, + totalComponentCost: 99999f, + budgetLimit: 0f, + boardArea: 20, + targetArea: 10, + traceCount: 0); + + var result = _scoringSystem.Calculate(input); + + Assert.AreEqual(500, result.BudgetBonus, "budgetLimit=0 should auto-pass and grant 500 budget bonus"); + } + + [Test] + public void Calculate_NoBudgetLimit_LineItemContainsBudgetNoLimit() + { + var input = new ScoringInput(true, 99999f, 0f, 5, 10, 0); + + var result = _scoringSystem.Calculate(input); + + bool found = false; + foreach (var item in result.LineItems) + { + if (item.Label.Contains("Budget: No Limit")) + { + found = true; + break; + } + } + Assert.IsTrue(found, "LineItems should show 'Budget: No Limit' when budgetLimit is 0"); + } + + // ------------------------------------------------------------------ // + // Area bonus linear scaling + // ------------------------------------------------------------------ // + + [Test] + public void Calculate_AreaEqualToTarget_AreaBonusIs300() + { + // boardArea == targetArea → ratio=1.0 → factor=min(1, 2-1)=1 → bonus=300 + var input = new ScoringInput(true, 50f, 100f, 10, 10, 0); + + var result = _scoringSystem.Calculate(input); + + Assert.AreEqual(300, result.AreaBonus, "Area bonus should be 300 when board == target area"); + } + + [Test] + public void Calculate_AreaTwiceTarget_AreaBonusIs0() + { + // boardArea == 2*targetArea → ratio=2.0 → factor=max(0, 0)=0 → bonus=0 + var input = new ScoringInput(true, 50f, 100f, 20, 10, 0); + + var result = _scoringSystem.Calculate(input); + + Assert.AreEqual(0, result.AreaBonus, "Area bonus should be 0 when board is twice the target area"); + } + + [Test] + public void Calculate_Area1p5xTarget_AreaBonusIs150() + { + // boardArea=15, targetArea=10 → ratio=1.5 → factor=max(0, min(1, 2-1.5))=0.5 → bonus=300*0.5=150 + var input = new ScoringInput(true, 50f, 100f, 15, 10, 0); + + var result = _scoringSystem.Calculate(input); + + Assert.AreEqual(150, result.AreaBonus, 1, + "Area bonus should be ~150 when board is 1.5x the target area"); + } + + [Test] + public void Calculate_AreaHalfTarget_AreaBonusIs300() + { + // boardArea < targetArea → ratio<1 → factor=min(1, something>1)=1 → bonus=300 + var input = new ScoringInput(true, 50f, 100f, 5, 10, 0); + + var result = _scoringSystem.Calculate(input); + + Assert.AreEqual(300, result.AreaBonus, "Area bonus is capped at 300 even when well under target"); + } + + // ------------------------------------------------------------------ // + // Line item label verification + // ------------------------------------------------------------------ // + + [Test] + public void Calculate_Passed_LineItemContainsCircuitWorks() + { + var input = new ScoringInput(true, 50f, 100f, 5, 10, 0); + + var result = _scoringSystem.Calculate(input); + + bool found = false; + foreach (var item in result.LineItems) + { + if (item.Label.Contains("Circuit Works")) + { + found = true; + break; + } + } + Assert.IsTrue(found, "LineItems should contain 'Circuit Works' on pass"); + } + + [Test] + public void Calculate_OverBudget_LineItemContainsBudgetValues() + { + var input = new ScoringInput(true, 150f, 100f, 20, 10, 0); + + var result = _scoringSystem.Calculate(input); + + bool found = false; + foreach (var item in result.LineItems) + { + if (item.Label.Contains("150") && item.Label.Contains("100")) + { + found = true; + break; + } + } + Assert.IsTrue(found, "LineItems should contain cost/limit values in budget line"); + } + + [Test] + public void Calculate_UnderBudget_LineItemContainsBudgetValues() + { + var input = new ScoringInput(true, 50f, 100f, 20, 10, 0); + + var result = _scoringSystem.Calculate(input); + + bool found = false; + foreach (var item in result.LineItems) + { + if (item.Label.Contains("50") && item.Label.Contains("100")) + { + found = true; + break; + } + } + Assert.IsTrue(found, "LineItems should contain cost/limit values in budget line"); + } + + [Test] + public void Calculate_AreaLine_ContainsBoardAreaAndTargetArea() + { + var input = new ScoringInput(true, 50f, 100f, 7, 10, 0); + + var result = _scoringSystem.Calculate(input); + + bool found = false; + foreach (var item in result.LineItems) + { + if (item.Label.Contains("7") && item.Label.Contains("10")) + { + found = true; + break; + } + } + Assert.IsTrue(found, "LineItems should contain boardArea/targetArea values in area line"); + } + + // ------------------------------------------------------------------ // + // Summary text + // ------------------------------------------------------------------ // + + [Test] + public void Calculate_Passed_SummaryContainsStarCount() + { + var input = new ScoringInput(true, 50f, 100f, 5, 10, 0); + + var result = _scoringSystem.Calculate(input); + + StringAssert.Contains("3 Stars", result.Summary); + } + + [Test] + public void Calculate_1Star_SummaryContainsSingularStar() + { + var input = new ScoringInput(true, 150f, 100f, 20, 10, 0); + + var result = _scoringSystem.Calculate(input); + + StringAssert.Contains("1 Star", result.Summary); + } + + // ------------------------------------------------------------------ // + // Edge cases + // ------------------------------------------------------------------ // + + [Test] + public void Calculate_ExactlyAtBudgetLimit_EarnsBonus() + { + var input = new ScoringInput(true, 100f, 100f, 20, 10, 0); + + var result = _scoringSystem.Calculate(input); + + Assert.AreEqual(500, result.BudgetBonus, + "Cost exactly at limit should still earn budget bonus"); + } + + [Test] + public void Calculate_ZeroTargetArea_DoesNotCrash() + { + // targetArea=0 → clamped to max(1,0)=1, boardArea=0 → clamped to 1 → ratio=1 → bonus=300 + var input = new ScoringInput(true, 50f, 100f, 0, 0, 0); + + // Should not throw + var result = _scoringSystem.Calculate(input); + + Assert.IsNotNull(result, "Result should not be null even with zero areas"); + } + + [Test] + public void Calculate_LineItemsCount_MatchesExpectedStructure() + { + // Passed circuit always produces: Circuit Works + budget line + area line = 3 items + var input = new ScoringInput(true, 50f, 100f, 5, 10, 0); + + var result = _scoringSystem.Calculate(input); + + Assert.AreEqual(3, result.LineItems.Count, + "Passed circuit should have 3 line items: circuit, budget, area"); + } + + [Test] + public void Calculate_FailedCircuit_LineItemsCount_Is1() + { + // Fail early-returns after adding Circuit Failed item only + var input = new ScoringInput(false, 0f, 0f, 0, 0, 0); + + var result = _scoringSystem.Calculate(input); + + Assert.AreEqual(1, result.LineItems.Count, + "Failed circuit should have exactly 1 line item"); + } + } +} diff --git a/Assets/10_Scripts/30_Tests/10_Core/ScoringSystemTests.cs.meta b/Assets/10_Scripts/30_Tests/10_Core/ScoringSystemTests.cs.meta new file mode 100644 index 0000000..995537d --- /dev/null +++ b/Assets/10_Scripts/30_Tests/10_Core/ScoringSystemTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0dc95e21fe145e243a2387dd2335c980 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/10_Core/SimulationStrategyTests.cs b/Assets/10_Scripts/30_Tests/10_Core/SimulationStrategyTests.cs new file mode 100644 index 0000000..8abd549 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/10_Core/SimulationStrategyTests.cs @@ -0,0 +1,54 @@ +using System.Threading; +using NUnit.Framework; +using CircuitCraft.Simulation; +using CircuitCraft.Simulation.SpiceSharp; + +namespace CircuitCraft.Tests.Core +{ + [TestFixture] + public class SimulationStrategyTests + { + [Test] + public void DCOperatingPointStrategy_Execute_ReturnsVoltageDividerResult() + { + var netlist = new CircuitNetlist { Title = "Strategy DC Test" }; + netlist.AddElement(NetlistElement.VoltageSource("V1", "in", "0", 5.0)); + netlist.AddElement(NetlistElement.Resistor("R1", "in", "out", 1000.0)); + netlist.AddElement(NetlistElement.Resistor("R2", "out", "0", 2000.0)); + netlist.AddProbe(ProbeDefinition.Voltage("V_out", "out")); + + var circuit = new NetlistBuilder().Build(netlist); + var strategy = new DCOperatingPointStrategy(); + + var result = strategy.Execute(circuit, netlist, CancellationToken.None); + + Assert.IsTrue(result.IsSuccess, result.StatusMessage); + var vOut = result.GetVoltage("out"); + Assert.IsNotNull(vOut); + Assert.AreEqual(3.3333333333, vOut.Value, 0.01); + } + + [Test] + public void TransientAnalysisStrategy_Execute_ReturnsRCWaveform() + { + var netlist = new CircuitNetlist { Title = "Strategy Transient Test" }; + netlist.AddElement(NetlistElement.VoltageSource("V1", "in", "0", 5.0)); + netlist.AddElement(NetlistElement.Resistor("R1", "in", "cap", 1e3)); + netlist.AddElement(NetlistElement.Capacitor("C1", "cap", "0", 1e-6)); + netlist.AddProbe(ProbeDefinition.Voltage("V_cap", "cap")); + + var circuit = new NetlistBuilder().Build(netlist); + var strategy = new TransientAnalysisStrategy(new TransientConfig(stopTime: 5e-3, maxStep: 5e-5)); + + var result = strategy.Execute(circuit, netlist, CancellationToken.None); + + Assert.IsTrue(result.IsSuccess, result.StatusMessage); + var probe = result.GetProbe("V_cap"); + Assert.IsNotNull(probe); + Assert.Greater(probe.TimePoints.Count, 1); + Assert.AreEqual(probe.TimePoints.Count, probe.Values.Count); + Assert.AreEqual(0.0, probe.Values[0], 1e-6); + Assert.Greater(probe.Values[probe.Values.Count - 1], 4.8); + } + } +} diff --git a/Assets/10_Scripts/30_Tests/10_Core/SimulationStrategyTests.cs.meta b/Assets/10_Scripts/30_Tests/10_Core/SimulationStrategyTests.cs.meta new file mode 100644 index 0000000..8fc1766 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/10_Core/SimulationStrategyTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bb8656eba1ce5c3469195365a75784a2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/20_Tests/Integration.meta b/Assets/10_Scripts/30_Tests/10_Integration.meta similarity index 100% rename from Assets/10_Scripts/20_Tests/Integration.meta rename to Assets/10_Scripts/30_Tests/10_Integration.meta diff --git a/Assets/10_Scripts/20_Tests/Integration/BoardStateSimulationTests.cs b/Assets/10_Scripts/30_Tests/10_Integration/BoardStateSimulationTests.cs similarity index 99% rename from Assets/10_Scripts/20_Tests/Integration/BoardStateSimulationTests.cs rename to Assets/10_Scripts/30_Tests/10_Integration/BoardStateSimulationTests.cs index a434798..c4fa935 100644 --- a/Assets/10_Scripts/20_Tests/Integration/BoardStateSimulationTests.cs +++ b/Assets/10_Scripts/30_Tests/10_Integration/BoardStateSimulationTests.cs @@ -19,7 +19,7 @@ namespace CircuitCraft.Tests.Integration [TestFixture] public class BoardStateSimulationTests { - private const string TestAssetBasePath = "Assets/70_Data/10_ScriptableObjects/Components"; + private const string TestAssetBasePath = "Assets/70_Data/10_ScriptableObjects/40_Components"; private const string VSourceAssetName = "test_vsource_5v"; private const string Resistor1kAssetName = "test_resistor_1k"; private const string Resistor2kAssetName = "test_resistor_2k"; diff --git a/Assets/10_Scripts/20_Tests/Integration/BoardStateSimulationTests.cs.meta b/Assets/10_Scripts/30_Tests/10_Integration/BoardStateSimulationTests.cs.meta similarity index 100% rename from Assets/10_Scripts/20_Tests/Integration/BoardStateSimulationTests.cs.meta rename to Assets/10_Scripts/30_Tests/10_Integration/BoardStateSimulationTests.cs.meta diff --git a/Assets/10_Scripts/30_Tests/10_Utils.meta b/Assets/10_Scripts/30_Tests/10_Utils.meta new file mode 100644 index 0000000..19d5792 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/10_Utils.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: aba345eac5178c7438e785699a0c20e3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/10_Utils/CircuitUnitFormatterTests.cs b/Assets/10_Scripts/30_Tests/10_Utils/CircuitUnitFormatterTests.cs new file mode 100644 index 0000000..f07c935 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/10_Utils/CircuitUnitFormatterTests.cs @@ -0,0 +1,219 @@ +using NUnit.Framework; +using CircuitCraft.Utils; + +namespace CircuitCraft.Tests.Utils +{ + /// + /// NUnit EditMode tests for CircuitUnitFormatter utility class. + /// Covers all five public formatting methods with boundary values and edge cases. + /// + [TestFixture] + public class CircuitUnitFormatterTests + { + // ───────────────────────────────────────────────────────────────────── + // FormatResistance + // ───────────────────────────────────────────────────────────────────── + + [Test] + public void FormatResistance_Below1k_ReturnsOhms() + { + Assert.AreEqual("100Ω", CircuitUnitFormatter.FormatResistance(100f)); + } + + [Test] + public void FormatResistance_ExactlyAt1k_ReturnskOhms() + { + // threshold is >= 1000, so 1000 should produce kΩ + Assert.AreEqual("1kΩ", CircuitUnitFormatter.FormatResistance(1000f)); + } + + [Test] + public void FormatResistance_Just_Below1k_ReturnsOhms() + { + Assert.AreEqual("999Ω", CircuitUnitFormatter.FormatResistance(999f)); + } + + [Test] + public void FormatResistance_4700_Returns4Point7kOhms() + { + Assert.AreEqual("4.7kΩ", CircuitUnitFormatter.FormatResistance(4700f)); + } + + [Test] + public void FormatResistance_ExactlyAt1M_ReturnsMOhms() + { + // threshold is >= 1_000_000, so 1_000_000 should produce MΩ + Assert.AreEqual("1MΩ", CircuitUnitFormatter.FormatResistance(1_000_000f)); + } + + [Test] + public void FormatResistance_2200000_Returns2Point2MOhms() + { + Assert.AreEqual("2.2MΩ", CircuitUnitFormatter.FormatResistance(2_200_000f)); + } + + [Test] + public void FormatResistance_Zero_ReturnsZeroOhms() + { + Assert.AreEqual("0Ω", CircuitUnitFormatter.FormatResistance(0f)); + } + + // ───────────────────────────────────────────────────────────────────── + // FormatCapacitance + // ───────────────────────────────────────────────────────────────────── + + [Test] + public void FormatCapacitance_1Microfarad_ReturnsMicrofarads() + { + // 0.000001 F = 1 µF + Assert.AreEqual("1µF", CircuitUnitFormatter.FormatCapacitance(0.000001f)); + } + + [Test] + public void FormatCapacitance_100Nanofarad_ReturnsNanofarads() + { + // 100 nF = 0.0000001 F — between nF and µF threshold + Assert.AreEqual("100nF", CircuitUnitFormatter.FormatCapacitance(0.0000001f)); + } + + [Test] + public void FormatCapacitance_1Nanofarad_ReturnsNanofarads() + { + // 0.000000001 F = 1 nF (exact threshold) + Assert.AreEqual("1nF", CircuitUnitFormatter.FormatCapacitance(0.000000001f)); + } + + [Test] + public void FormatCapacitance_1Picofarad_ReturnsPicofarads() + { + // 0.000000000001 F = 1 pF (below nF threshold) + Assert.AreEqual("1pF", CircuitUnitFormatter.FormatCapacitance(0.000000000001f)); + } + + [Test] + public void FormatCapacitance_1Farad_ReturnsFarads() + { + Assert.AreEqual("1F", CircuitUnitFormatter.FormatCapacitance(1f)); + } + + [Test] + public void FormatCapacitance_1Millifarad_ReturnsMillifarads() + { + // 0.001 F = 1 mF (exact mF threshold) + Assert.AreEqual("1mF", CircuitUnitFormatter.FormatCapacitance(0.001f)); + } + + // ───────────────────────────────────────────────────────────────────── + // FormatInductance + // ───────────────────────────────────────────────────────────────────── + + [Test] + public void FormatInductance_1Millihenry_ReturnsMillihenrys() + { + // 0.001 H = 1 mH (exact mH threshold) + Assert.AreEqual("1mH", CircuitUnitFormatter.FormatInductance(0.001f)); + } + + [Test] + public void FormatInductance_1Microhenry_ReturnsMicrohenrys() + { + // 0.000001 H = 1 µH (exact µH threshold) + Assert.AreEqual("1µH", CircuitUnitFormatter.FormatInductance(0.000001f)); + } + + [Test] + public void FormatInductance_1Henry_ReturnsHenrys() + { + Assert.AreEqual("1H", CircuitUnitFormatter.FormatInductance(1f)); + } + + [Test] + public void FormatInductance_BelowMicrohenry_ReturnsNanohenrys() + { + // 0.000000001 H = 1 nH + Assert.AreEqual("1nH", CircuitUnitFormatter.FormatInductance(0.000000001f)); + } + + [Test] + public void FormatInductance_100Millihenry_ReturnsMillihenrys() + { + // 0.1 H = 100 mH + Assert.AreEqual("100mH", CircuitUnitFormatter.FormatInductance(0.1f)); + } + + // ───────────────────────────────────────────────────────────────────── + // FormatVoltage + // ───────────────────────────────────────────────────────────────────── + + [Test] + public void FormatVoltage_PositiveValue_ReturnsThreeDecimalPlaces() + { + Assert.AreEqual("5.000 V", CircuitUnitFormatter.FormatVoltage(5.0)); + } + + [Test] + public void FormatVoltage_NegativeValue_ReturnsThreeDecimalPlaces() + { + // FormatVoltage is intentionally identical for positive and negative + Assert.AreEqual("-3.300 V", CircuitUnitFormatter.FormatVoltage(-3.3)); + } + + [Test] + public void FormatVoltage_Zero_ReturnsZeroVolts() + { + Assert.AreEqual("0.000 V", CircuitUnitFormatter.FormatVoltage(0.0)); + } + + // ───────────────────────────────────────────────────────────────────── + // FormatCurrent + // ───────────────────────────────────────────────────────────────────── + + [Test] + public void FormatCurrent_1Milliamp_ReturnsMilliamps() + { + // 0.001 A = 1 mA (exact mA threshold) + Assert.AreEqual("1 mA", CircuitUnitFormatter.FormatCurrent(0.001)); + } + + [Test] + public void FormatCurrent_1Microamp_ReturnsMicroamps() + { + // 0.000001 A = 1 µA (exact µA threshold) + Assert.AreEqual("1 µA", CircuitUnitFormatter.FormatCurrent(0.000001)); + } + + [Test] + public void FormatCurrent_1Amp_ReturnsAmps() + { + Assert.AreEqual("1 A", CircuitUnitFormatter.FormatCurrent(1.0)); + } + + [Test] + public void FormatCurrent_1Kiloamp_ReturnsKiloamps() + { + // 1000 A = 1 kA (exact kA threshold) + Assert.AreEqual("1 kA", CircuitUnitFormatter.FormatCurrent(1000.0)); + } + + [Test] + public void FormatCurrent_NegativeMilliamp_PreservesSign() + { + // FormatCurrent uses Math.Abs for scale, but preserves sign in output + Assert.AreEqual("-1 mA", CircuitUnitFormatter.FormatCurrent(-0.001)); + } + + [Test] + public void FormatCurrent_BelowMicroamp_ReturnsNanoamps() + { + // 0.000000001 A = 1 nA + Assert.AreEqual("1 nA", CircuitUnitFormatter.FormatCurrent(0.000000001)); + } + + [Test] + public void FormatCurrent_Zero_ReturnsZeroNanoamps() + { + // 0 A → abs is 0 → falls through all thresholds → nA range + Assert.AreEqual("0 nA", CircuitUnitFormatter.FormatCurrent(0.0)); + } + } +} diff --git a/Assets/10_Scripts/30_Tests/10_Utils/CircuitUnitFormatterTests.cs.meta b/Assets/10_Scripts/30_Tests/10_Utils/CircuitUnitFormatterTests.cs.meta new file mode 100644 index 0000000..7188753 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/10_Utils/CircuitUnitFormatterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b1e9114b45b014849b16c17507796964 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/10_Utils/GridUtilityTests.cs b/Assets/10_Scripts/30_Tests/10_Utils/GridUtilityTests.cs new file mode 100644 index 0000000..8c00406 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/10_Utils/GridUtilityTests.cs @@ -0,0 +1,198 @@ +using NUnit.Framework; +using UnityEngine; +using CircuitCraft.Utils; + +namespace CircuitCraft.Tests.Utils +{ + /// + /// NUnit EditMode characterization tests for GridUtility static utility class. + /// Documents current behavior for GridToWorldPosition and IsInsideSuggestedArea. + /// ScreenToGridPosition is excluded — requires Camera, not available in EditMode. + /// + [TestFixture] + public class GridUtilityTests + { + // ───────────────────────────────────────────────────────────────────── + // GridToWorldPosition + // ───────────────────────────────────────────────────────────────────── + + [Test] + public void GridToWorldPosition_OriginZero_CellSizeOne_GridPosZero_ReturnsWorldOrigin() + { + Vector3 origin = Vector3.zero; + float cellSize = 1f; + Vector2Int gridPos = new Vector2Int(0, 0); + + Vector3 result = GridUtility.GridToWorldPosition(gridPos, cellSize, origin); + + Assert.AreEqual(new Vector3(0f, 0f, 0f), result); + } + + [Test] + public void GridToWorldPosition_OriginZero_CellSizeOne_GridPos3x5_ReturnsWorld3x0x5() + { + Vector3 origin = Vector3.zero; + float cellSize = 1f; + Vector2Int gridPos = new Vector2Int(3, 5); + + Vector3 result = GridUtility.GridToWorldPosition(gridPos, cellSize, origin); + + // gridPosition.y maps to worldZ, gridPosition.x maps to worldX + Assert.AreEqual(new Vector3(3f, 0f, 5f), result); + } + + [Test] + public void GridToWorldPosition_CellSizeTwo_GridPos1x1_ReturnsWorld2x0x2() + { + Vector3 origin = Vector3.zero; + float cellSize = 2f; + Vector2Int gridPos = new Vector2Int(1, 1); + + Vector3 result = GridUtility.GridToWorldPosition(gridPos, cellSize, origin); + + Assert.AreEqual(new Vector3(2f, 0f, 2f), result); + } + + [Test] + public void GridToWorldPosition_NegativeGridPos_ReturnsNegativeWorldXZ() + { + Vector3 origin = Vector3.zero; + float cellSize = 1f; + Vector2Int gridPos = new Vector2Int(-1, -2); + + Vector3 result = GridUtility.GridToWorldPosition(gridPos, cellSize, origin); + + Assert.AreEqual(new Vector3(-1f, 0f, -2f), result); + } + + [Test] + public void GridToWorldPosition_NonZeroOrigin_OffsetIsAdded() + { + Vector3 origin = new Vector3(10f, 0f, 10f); + float cellSize = 1f; + Vector2Int gridPos = new Vector2Int(3, 3); + + Vector3 result = GridUtility.GridToWorldPosition(gridPos, cellSize, origin); + + Assert.AreEqual(new Vector3(13f, 0f, 13f), result); + } + + [Test] + public void GridToWorldPosition_OriginWithNonZeroY_YComponentPreserved() + { + Vector3 origin = new Vector3(0f, 5f, 0f); + float cellSize = 1f; + Vector2Int gridPos = new Vector2Int(1, 1); + + Vector3 result = GridUtility.GridToWorldPosition(gridPos, cellSize, origin); + + Assert.AreEqual(5f, result.y, 0.0001f, "Y component of gridOrigin must be preserved in world result."); + } + + // ───────────────────────────────────────────────────────────────────── + // IsInsideSuggestedArea + // ───────────────────────────────────────────────────────────────────── + + [Test] + public void IsInsideSuggestedArea_OriginPos_5x5Area_ReturnsTrue() + { + bool result = GridUtility.IsInsideSuggestedArea(new Vector2Int(0, 0), 5, 5); + + Assert.IsTrue(result, "(0,0) should be inside a 5x5 suggested area."); + } + + [Test] + public void IsInsideSuggestedArea_EdgePos4x4_5x5Area_ReturnsTrue() + { + bool result = GridUtility.IsInsideSuggestedArea(new Vector2Int(4, 4), 5, 5); + + Assert.IsTrue(result, "(4,4) is the last valid position in a 5x5 area."); + } + + [Test] + public void IsInsideSuggestedArea_XEqualsWidth_ReturnsFalse() + { + bool result = GridUtility.IsInsideSuggestedArea(new Vector2Int(5, 0), 5, 5); + + Assert.IsFalse(result, "(5,0) is out-of-bounds on X in a 5x5 area."); + } + + [Test] + public void IsInsideSuggestedArea_YEqualsHeight_ReturnsFalse() + { + bool result = GridUtility.IsInsideSuggestedArea(new Vector2Int(0, 5), 5, 5); + + Assert.IsFalse(result, "(0,5) is out-of-bounds on Y in a 5x5 area."); + } + + [Test] + public void IsInsideSuggestedArea_NegativeX_ReturnsFalse() + { + bool result = GridUtility.IsInsideSuggestedArea(new Vector2Int(-1, 0), 5, 5); + + Assert.IsFalse(result, "Negative X position must be outside the suggested area."); + } + + [Test] + public void IsInsideSuggestedArea_NegativeY_ReturnsFalse() + { + bool result = GridUtility.IsInsideSuggestedArea(new Vector2Int(0, -1), 5, 5); + + Assert.IsFalse(result, "Negative Y position must be outside the suggested area."); + } + + // ───────────────────────────────────────────────────────────────────── + // IsValidGridPosition (Obsolete — characterization tests to document behavior) + // ───────────────────────────────────────────────────────────────────── + +#pragma warning disable CS0618 + [Test] + public void IsValidGridPosition_OriginPos_5x5Grid_ReturnsTrue() + { + bool result = GridUtility.IsValidGridPosition(new Vector2Int(0, 0), 5, 5); + + Assert.IsTrue(result, "(0,0) should be valid in a 5x5 grid."); + } + + [Test] + public void IsValidGridPosition_EdgePos4x4_5x5Grid_ReturnsTrue() + { + bool result = GridUtility.IsValidGridPosition(new Vector2Int(4, 4), 5, 5); + + Assert.IsTrue(result, "(4,4) is the last valid position in a 5x5 grid."); + } + + [Test] + public void IsValidGridPosition_XEqualsWidth_ReturnsFalse() + { + bool result = GridUtility.IsValidGridPosition(new Vector2Int(5, 0), 5, 5); + + Assert.IsFalse(result, "(5,0) is out-of-bounds on X in a 5x5 grid."); + } + + [Test] + public void IsValidGridPosition_YEqualsHeight_ReturnsFalse() + { + bool result = GridUtility.IsValidGridPosition(new Vector2Int(0, 5), 5, 5); + + Assert.IsFalse(result, "(0,5) is out-of-bounds on Y in a 5x5 grid."); + } + + [Test] + public void IsValidGridPosition_NegativeX_ReturnsFalse() + { + bool result = GridUtility.IsValidGridPosition(new Vector2Int(-1, 0), 5, 5); + + Assert.IsFalse(result, "Negative X must be out-of-bounds."); + } + + [Test] + public void IsValidGridPosition_NegativeY_ReturnsFalse() + { + bool result = GridUtility.IsValidGridPosition(new Vector2Int(0, -1), 5, 5); + + Assert.IsFalse(result, "Negative Y must be out-of-bounds."); + } +#pragma warning restore CS0618 + } +} diff --git a/Assets/10_Scripts/30_Tests/10_Utils/GridUtilityTests.cs.meta b/Assets/10_Scripts/30_Tests/10_Utils/GridUtilityTests.cs.meta new file mode 100644 index 0000000..0a52c90 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/10_Utils/GridUtilityTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dd0a4be7256995b47a05dd98468d2ddb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/20_Tests/Simulation.meta b/Assets/10_Scripts/30_Tests/20_Simulation.meta similarity index 100% rename from Assets/10_Scripts/20_Tests/Simulation.meta rename to Assets/10_Scripts/30_Tests/20_Simulation.meta diff --git a/Assets/10_Scripts/20_Tests/Simulation/BJTTests.cs b/Assets/10_Scripts/30_Tests/20_Simulation/BJTTests.cs similarity index 100% rename from Assets/10_Scripts/20_Tests/Simulation/BJTTests.cs rename to Assets/10_Scripts/30_Tests/20_Simulation/BJTTests.cs diff --git a/Assets/10_Scripts/20_Tests/Simulation/BJTTests.cs.meta b/Assets/10_Scripts/30_Tests/20_Simulation/BJTTests.cs.meta similarity index 100% rename from Assets/10_Scripts/20_Tests/Simulation/BJTTests.cs.meta rename to Assets/10_Scripts/30_Tests/20_Simulation/BJTTests.cs.meta diff --git a/Assets/10_Scripts/20_Tests/Simulation/InductorTests.cs b/Assets/10_Scripts/30_Tests/20_Simulation/InductorTests.cs similarity index 100% rename from Assets/10_Scripts/20_Tests/Simulation/InductorTests.cs rename to Assets/10_Scripts/30_Tests/20_Simulation/InductorTests.cs diff --git a/Assets/10_Scripts/20_Tests/Simulation/InductorTests.cs.meta b/Assets/10_Scripts/30_Tests/20_Simulation/InductorTests.cs.meta similarity index 100% rename from Assets/10_Scripts/20_Tests/Simulation/InductorTests.cs.meta rename to Assets/10_Scripts/30_Tests/20_Simulation/InductorTests.cs.meta diff --git a/Assets/10_Scripts/20_Tests/Simulation/LEDCircuitTests.cs b/Assets/10_Scripts/30_Tests/20_Simulation/LEDCircuitTests.cs similarity index 100% rename from Assets/10_Scripts/20_Tests/Simulation/LEDCircuitTests.cs rename to Assets/10_Scripts/30_Tests/20_Simulation/LEDCircuitTests.cs diff --git a/Assets/10_Scripts/20_Tests/Simulation/LEDCircuitTests.cs.meta b/Assets/10_Scripts/30_Tests/20_Simulation/LEDCircuitTests.cs.meta similarity index 100% rename from Assets/10_Scripts/20_Tests/Simulation/LEDCircuitTests.cs.meta rename to Assets/10_Scripts/30_Tests/20_Simulation/LEDCircuitTests.cs.meta diff --git a/Assets/10_Scripts/20_Tests/Simulation/MOSFETTests.cs b/Assets/10_Scripts/30_Tests/20_Simulation/MOSFETTests.cs similarity index 100% rename from Assets/10_Scripts/20_Tests/Simulation/MOSFETTests.cs rename to Assets/10_Scripts/30_Tests/20_Simulation/MOSFETTests.cs diff --git a/Assets/10_Scripts/20_Tests/Simulation/MOSFETTests.cs.meta b/Assets/10_Scripts/30_Tests/20_Simulation/MOSFETTests.cs.meta similarity index 100% rename from Assets/10_Scripts/20_Tests/Simulation/MOSFETTests.cs.meta rename to Assets/10_Scripts/30_Tests/20_Simulation/MOSFETTests.cs.meta diff --git a/Assets/10_Scripts/20_Tests/Simulation/RCTransientTests.cs b/Assets/10_Scripts/30_Tests/20_Simulation/RCTransientTests.cs similarity index 100% rename from Assets/10_Scripts/20_Tests/Simulation/RCTransientTests.cs rename to Assets/10_Scripts/30_Tests/20_Simulation/RCTransientTests.cs diff --git a/Assets/10_Scripts/20_Tests/Simulation/RCTransientTests.cs.meta b/Assets/10_Scripts/30_Tests/20_Simulation/RCTransientTests.cs.meta similarity index 100% rename from Assets/10_Scripts/20_Tests/Simulation/RCTransientTests.cs.meta rename to Assets/10_Scripts/30_Tests/20_Simulation/RCTransientTests.cs.meta diff --git a/Assets/10_Scripts/20_Tests/Simulation/VoltageDividerTests.cs b/Assets/10_Scripts/30_Tests/20_Simulation/VoltageDividerTests.cs similarity index 100% rename from Assets/10_Scripts/20_Tests/Simulation/VoltageDividerTests.cs rename to Assets/10_Scripts/30_Tests/20_Simulation/VoltageDividerTests.cs diff --git a/Assets/10_Scripts/20_Tests/Simulation/VoltageDividerTests.cs.meta b/Assets/10_Scripts/30_Tests/20_Simulation/VoltageDividerTests.cs.meta similarity index 100% rename from Assets/10_Scripts/20_Tests/Simulation/VoltageDividerTests.cs.meta rename to Assets/10_Scripts/30_Tests/20_Simulation/VoltageDividerTests.cs.meta diff --git a/Assets/10_Scripts/30_Tests/20_Systems.meta b/Assets/10_Scripts/30_Tests/20_Systems.meta new file mode 100644 index 0000000..6cf1a80 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/20_Systems.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1c59d9b0fb02cc5469ceaf586adcec7d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/20_Systems/BoardToNetlistConverterTests.cs b/Assets/10_Scripts/30_Tests/20_Systems/BoardToNetlistConverterTests.cs new file mode 100644 index 0000000..938830b --- /dev/null +++ b/Assets/10_Scripts/30_Tests/20_Systems/BoardToNetlistConverterTests.cs @@ -0,0 +1,1378 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using UnityEngine; +using CircuitCraft.Core; +using CircuitCraft.Data; +using CircuitCraft.Simulation; +using CircuitCraft.Systems; + +namespace CircuitCraft.Tests.Systems +{ + /// + /// Characterization tests for BoardToNetlistConverter — captures all current behavior + /// as a safety net before refactoring. Tests must pass against the current implementation. + /// + [TestFixture] + public class BoardToNetlistConverterTests + { + private BoardToNetlistConverter _converter; + private TestComponentDefinitionProvider _provider; + private List _createdDefinitions; + + // ── Lifecycle ──────────────────────────────────────────────────────────── + + [SetUp] + public void SetUp() + { + _createdDefinitions = new List(); + _provider = new TestComponentDefinitionProvider(); + _converter = new BoardToNetlistConverter(_provider); + } + + [TearDown] + public void TearDown() + { + foreach (var def in _createdDefinitions) + { + if (def != null) + UnityEngine.Object.DestroyImmediate(def); + } + _createdDefinitions.Clear(); + } + + // ── Stub Provider ──────────────────────────────────────────────────────── + + private class TestComponentDefinitionProvider : IComponentDefinitionProvider + { + private readonly Dictionary _definitions + = new Dictionary(); + + public void Register(ComponentDefinition def) => _definitions[def.Id] = def; + + public ComponentDefinition GetDefinition(string componentDefId) + { + _definitions.TryGetValue(componentDefId, out var def); + return def; + } + } + + // ── Helper: pin creation ───────────────────────────────────────────────── + + private static List CreatePins(int count) + { + var pins = new List(); + for (int i = 0; i < count; i++) + pins.Add(new PinInstance(i, $"pin{i}", new GridPosition(i, 0))); + return pins; + } + + // ── Helper: ComponentDefinition factory via reflection ──────────────────── + + private ComponentDefinition CreateDefinition(string id, ComponentKind kind, Action configure = null) + { + var def = ScriptableObject.CreateInstance(); + _createdDefinitions.Add(def); + + SetPrivateField(def, "_id", id); + SetPrivateField(def, "_displayName", id); + SetPrivateField(def, "_kind", kind); + + configure?.Invoke(def); + + _provider.Register(def); + return def; + } + + private ComponentDefinition CreateResistorDefinition(string id, float ohms) + { + return CreateDefinition(id, ComponentKind.Resistor, def => + SetPrivateField(def, "_resistanceOhms", ohms)); + } + + private ComponentDefinition CreateVoltageSourceDefinition(string id, float volts) + { + return CreateDefinition(id, ComponentKind.VoltageSource, def => + SetPrivateField(def, "_voltageVolts", volts)); + } + + private ComponentDefinition CreateCapacitorDefinition(string id, float farads) + { + return CreateDefinition(id, ComponentKind.Capacitor, def => + SetPrivateField(def, "_capacitanceFarads", farads)); + } + + private ComponentDefinition CreateInductorDefinition(string id, float henrys) + { + return CreateDefinition(id, ComponentKind.Inductor, def => + SetPrivateField(def, "_inductanceHenrys", henrys)); + } + + private ComponentDefinition CreateCurrentSourceDefinition(string id, float amps) + { + return CreateDefinition(id, ComponentKind.CurrentSource, def => + SetPrivateField(def, "_currentAmps", amps)); + } + + private ComponentDefinition CreateDiodeDefinition(string id, ComponentKind kind = ComponentKind.Diode) + { + return CreateDefinition(id, kind, def => + { + SetPrivateField(def, "_diodeModel", DiodeModel._1N4148); + SetPrivateField(def, "_saturationCurrent", 1e-9f); + SetPrivateField(def, "_emissionCoefficient", 1.8f); + SetPrivateField(def, "_breakdownVoltage", 0f); + SetPrivateField(def, "_breakdownCurrent", 0f); + }); + } + + private ComponentDefinition CreateZenerDefinition(string id) + { + return CreateDefinition(id, ComponentKind.ZenerDiode, def => + { + SetPrivateField(def, "_diodeModel", DiodeModel.Zener_5V1); + SetPrivateField(def, "_saturationCurrent", 1e-9f); + SetPrivateField(def, "_emissionCoefficient", 1.8f); + SetPrivateField(def, "_breakdownVoltage", 5.1f); + SetPrivateField(def, "_breakdownCurrent", 0.001f); + }); + } + + private ComponentDefinition CreateBJTDefinition(string id, BJTPolarity polarity = BJTPolarity.NPN) + { + return CreateDefinition(id, ComponentKind.BJT, def => + { + SetPrivateField(def, "_bjtPolarity", polarity); + SetPrivateField(def, "_bjtModel", BJTModel._2N2222); + SetPrivateField(def, "_beta", 200f); + SetPrivateField(def, "_earlyVoltage", 100f); + }); + } + + private ComponentDefinition CreateMOSFETDefinition(string id, FETPolarity polarity = FETPolarity.NChannel) + { + return CreateDefinition(id, ComponentKind.MOSFET, def => + { + SetPrivateField(def, "_fetPolarity", polarity); + SetPrivateField(def, "_mosfetModel", MOSFETModel._2N7000); + SetPrivateField(def, "_thresholdVoltage", 2.0f); + SetPrivateField(def, "_transconductance", 0.3f); + }); + } + + private static void SetPrivateField(object target, string fieldName, object value) + { + var type = target.GetType(); + // Walk up the hierarchy to find the field (ScriptableObject can inherit) + FieldInfo field = null; + while (type != null && field == null) + { + field = type.GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance); + type = type.BaseType; + } + + if (field == null) + throw new MissingFieldException($"Field '{fieldName}' not found on {target.GetType().Name}"); + + field.SetValue(target, value); + } + + // ── Helper: build a 2-pin component connected to two nets ──────────────── + + private PlacedComponent PlaceConnected2Pin(BoardState board, string defId, int x, int y, + int netIdA, int netIdB) + { + var comp = board.PlaceComponent(defId, new GridPosition(x, y), 0, CreatePins(2)); + board.ConnectPinToNet(netIdA, new PinReference(comp.InstanceId, 0, comp.GetPinWorldPosition(0))); + board.ConnectPinToNet(netIdB, new PinReference(comp.InstanceId, 1, comp.GetPinWorldPosition(1))); + return comp; + } + + private PlacedComponent PlaceConnected3Pin(BoardState board, string defId, int x, int y, + int netId0, int netId1, int netId2) + { + var comp = board.PlaceComponent(defId, new GridPosition(x, y), 0, CreatePins(3)); + board.ConnectPinToNet(netId0, new PinReference(comp.InstanceId, 0, comp.GetPinWorldPosition(0))); + board.ConnectPinToNet(netId1, new PinReference(comp.InstanceId, 1, comp.GetPinWorldPosition(1))); + board.ConnectPinToNet(netId2, new PinReference(comp.InstanceId, 2, comp.GetPinWorldPosition(2))); + return comp; + } + + // ════════════════════════════════════════════════════════════════════════ + // CONSTRUCTOR TESTS + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Constructor_NullProvider_ThrowsArgumentNullException() + { + Assert.Throws(() => new BoardToNetlistConverter(null)); + } + + [Test] + public void Constructor_ValidProvider_DoesNotThrow() + { + Assert.DoesNotThrow(() => new BoardToNetlistConverter(_provider)); + } + + // ════════════════════════════════════════════════════════════════════════ + // CONVERT - ARGUMENT GUARD TESTS + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_NullBoardState_ThrowsArgumentNullException() + { + Assert.Throws(() => _converter.Convert(null)); + } + + // ════════════════════════════════════════════════════════════════════════ + // CONVERT - EMPTY BOARD + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_EmptyBoard_ReturnsNetlistWithCorrectTitle() + { + var board = new BoardState(10, 10); + + var netlist = _converter.Convert(board); + + Assert.AreEqual("BoardState Circuit", netlist.Title); + } + + [Test] + public void Convert_EmptyBoard_ReturnsNetlistWithGroundNodeZero() + { + var board = new BoardState(10, 10); + + var netlist = _converter.Convert(board); + + Assert.AreEqual("0", netlist.GroundNode); + } + + [Test] + public void Convert_EmptyBoard_ReturnsNetlistWithZeroElements() + { + var board = new BoardState(10, 10); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(0, netlist.Elements.Count); + } + + [Test] + public void Convert_EmptyBoard_ReturnsNetlistWithZeroProbes() + { + var board = new BoardState(10, 10); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(0, netlist.Probes.Count); + } + + // ════════════════════════════════════════════════════════════════════════ + // RESISTOR CONVERSION + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_SingleResistor_ElementIdUsesRPrefix() + { + CreateResistorDefinition("resistor_1k", 1000f); + var board = new BoardState(10, 10); + var netA = board.CreateNet("NET_A"); + var netB = board.CreateNet("NET_B"); + var comp = PlaceConnected2Pin(board, "resistor_1k", 0, 0, netA.NetId, netB.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(1, netlist.Elements.Count); + Assert.AreEqual($"R{comp.InstanceId}", netlist.Elements[0].Id); + } + + [Test] + public void Convert_SingleResistor_ElementTypeIsResistor() + { + CreateResistorDefinition("resistor_1k", 1000f); + var board = new BoardState(10, 10); + var netA = board.CreateNet("NET_A"); + var netB = board.CreateNet("NET_B"); + PlaceConnected2Pin(board, "resistor_1k", 0, 0, netA.NetId, netB.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(ElementType.Resistor, netlist.Elements[0].Type); + } + + [Test] + public void Convert_SingleResistor_NodesMatchNetNames() + { + CreateResistorDefinition("resistor_1k", 1000f); + var board = new BoardState(10, 10); + var netA = board.CreateNet("NET_A"); + var netB = board.CreateNet("NET_B"); + PlaceConnected2Pin(board, "resistor_1k", 0, 0, netA.NetId, netB.NetId); + + var netlist = _converter.Convert(board); + + var elem = netlist.Elements[0]; + Assert.Contains("NET_A", elem.Nodes); + Assert.Contains("NET_B", elem.Nodes); + } + + [Test] + public void Convert_SingleResistor_ValueMatchesDefinitionResistance() + { + CreateResistorDefinition("resistor_2k", 2000f); + var board = new BoardState(10, 10); + var netA = board.CreateNet("NET_A"); + var netB = board.CreateNet("NET_B"); + PlaceConnected2Pin(board, "resistor_2k", 0, 0, netA.NetId, netB.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(2000.0, netlist.Elements[0].Value, 1e-9); + } + + // ════════════════════════════════════════════════════════════════════════ + // VOLTAGE SOURCE CONVERSION + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_SingleVoltageSource_ElementIdUsesVPrefix() + { + CreateVoltageSourceDefinition("vsource_5v", 5f); + var board = new BoardState(10, 10); + var netPos = board.CreateNet("VIN"); + var netNeg = board.CreateNet("GND"); + var comp = PlaceConnected2Pin(board, "vsource_5v", 0, 0, netPos.NetId, netNeg.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual($"V{comp.InstanceId}", netlist.Elements[0].Id); + } + + [Test] + public void Convert_SingleVoltageSource_ElementTypeIsVoltageSource() + { + CreateVoltageSourceDefinition("vsource_5v", 5f); + var board = new BoardState(10, 10); + var netPos = board.CreateNet("VIN"); + var netNeg = board.CreateNet("GND"); + PlaceConnected2Pin(board, "vsource_5v", 0, 0, netPos.NetId, netNeg.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(ElementType.VoltageSource, netlist.Elements[0].Type); + } + + [Test] + public void Convert_SingleVoltageSource_ValueMatchesDefinitionVoltage() + { + CreateVoltageSourceDefinition("vsource_9v", 9f); + var board = new BoardState(10, 10); + var netPos = board.CreateNet("VIN"); + var netNeg = board.CreateNet("GND"); + PlaceConnected2Pin(board, "vsource_9v", 0, 0, netPos.NetId, netNeg.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(9.0, netlist.Elements[0].Value, 1e-9); + } + + // ════════════════════════════════════════════════════════════════════════ + // CAPACITOR CONVERSION + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_SingleCapacitor_ElementIdUsesCPrefix() + { + CreateCapacitorDefinition("cap_100nf", 100e-9f); + var board = new BoardState(10, 10); + var netA = board.CreateNet("NET_A"); + var netB = board.CreateNet("NET_B"); + var comp = PlaceConnected2Pin(board, "cap_100nf", 0, 0, netA.NetId, netB.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual($"C{comp.InstanceId}", netlist.Elements[0].Id); + } + + [Test] + public void Convert_SingleCapacitor_ElementTypeIsCapacitor() + { + CreateCapacitorDefinition("cap_100nf", 100e-9f); + var board = new BoardState(10, 10); + var netA = board.CreateNet("NET_A"); + var netB = board.CreateNet("NET_B"); + PlaceConnected2Pin(board, "cap_100nf", 0, 0, netA.NetId, netB.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(ElementType.Capacitor, netlist.Elements[0].Type); + } + + [Test] + public void Convert_SingleCapacitor_ValueMatchesDefinitionCapacitance() + { + CreateCapacitorDefinition("cap_100nf", 100e-9f); + var board = new BoardState(10, 10); + var netA = board.CreateNet("NET_A"); + var netB = board.CreateNet("NET_B"); + PlaceConnected2Pin(board, "cap_100nf", 0, 0, netA.NetId, netB.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(100e-9, netlist.Elements[0].Value, 1e-18); + } + + // ════════════════════════════════════════════════════════════════════════ + // INDUCTOR CONVERSION + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_SingleInductor_ElementIdUsesLPrefix() + { + CreateInductorDefinition("inductor_1mh", 1e-3f); + var board = new BoardState(10, 10); + var netA = board.CreateNet("NET_A"); + var netB = board.CreateNet("NET_B"); + var comp = PlaceConnected2Pin(board, "inductor_1mh", 0, 0, netA.NetId, netB.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual($"L{comp.InstanceId}", netlist.Elements[0].Id); + } + + [Test] + public void Convert_SingleInductor_ElementTypeIsInductor() + { + CreateInductorDefinition("inductor_1mh", 1e-3f); + var board = new BoardState(10, 10); + var netA = board.CreateNet("NET_A"); + var netB = board.CreateNet("NET_B"); + PlaceConnected2Pin(board, "inductor_1mh", 0, 0, netA.NetId, netB.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(ElementType.Inductor, netlist.Elements[0].Type); + } + + [Test] + public void Convert_SingleInductor_ValueMatchesDefinitionInductance() + { + CreateInductorDefinition("inductor_1mh", 1e-3f); + var board = new BoardState(10, 10); + var netA = board.CreateNet("NET_A"); + var netB = board.CreateNet("NET_B"); + PlaceConnected2Pin(board, "inductor_1mh", 0, 0, netA.NetId, netB.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(1e-3, netlist.Elements[0].Value, 1e-12); + } + + // ════════════════════════════════════════════════════════════════════════ + // CURRENT SOURCE CONVERSION + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_SingleCurrentSource_ElementIdUsesIPrefix() + { + CreateCurrentSourceDefinition("isource_10ma", 0.01f); + var board = new BoardState(10, 10); + var netA = board.CreateNet("NET_A"); + var netB = board.CreateNet("NET_B"); + var comp = PlaceConnected2Pin(board, "isource_10ma", 0, 0, netA.NetId, netB.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual($"I{comp.InstanceId}", netlist.Elements[0].Id); + } + + [Test] + public void Convert_SingleCurrentSource_ElementTypeIsCurrentSource() + { + CreateCurrentSourceDefinition("isource_10ma", 0.01f); + var board = new BoardState(10, 10); + var netA = board.CreateNet("NET_A"); + var netB = board.CreateNet("NET_B"); + PlaceConnected2Pin(board, "isource_10ma", 0, 0, netA.NetId, netB.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(ElementType.CurrentSource, netlist.Elements[0].Type); + } + + [Test] + public void Convert_SingleCurrentSource_ValueMatchesDefinitionCurrent() + { + CreateCurrentSourceDefinition("isource_10ma", 0.01f); + var board = new BoardState(10, 10); + var netA = board.CreateNet("NET_A"); + var netB = board.CreateNet("NET_B"); + PlaceConnected2Pin(board, "isource_10ma", 0, 0, netA.NetId, netB.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(0.01, netlist.Elements[0].Value, 1e-9); + } + + // ════════════════════════════════════════════════════════════════════════ + // DIODE CONVERSION + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_SingleDiode_ElementIdUsesDPrefix() + { + CreateDiodeDefinition("diode_1n4148"); + var board = new BoardState(10, 10); + var netA = board.CreateNet("ANODE"); + var netK = board.CreateNet("CATHODE"); + var comp = PlaceConnected2Pin(board, "diode_1n4148", 0, 0, netA.NetId, netK.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual($"D{comp.InstanceId}", netlist.Elements[0].Id); + } + + [Test] + public void Convert_SingleDiode_ElementTypeIsDiode() + { + CreateDiodeDefinition("diode_1n4148"); + var board = new BoardState(10, 10); + var netA = board.CreateNet("ANODE"); + var netK = board.CreateNet("CATHODE"); + PlaceConnected2Pin(board, "diode_1n4148", 0, 0, netA.NetId, netK.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(ElementType.Diode, netlist.Elements[0].Type); + } + + [Test] + public void Convert_SingleDiode_ModelNameIsSet() + { + CreateDiodeDefinition("diode_1n4148"); + var board = new BoardState(10, 10); + var netA = board.CreateNet("ANODE"); + var netK = board.CreateNet("CATHODE"); + PlaceConnected2Pin(board, "diode_1n4148", 0, 0, netA.NetId, netK.NetId); + + var netlist = _converter.Convert(board); + + Assert.IsFalse(string.IsNullOrEmpty(netlist.Elements[0].ModelName), + "Diode should have a model name"); + Assert.AreEqual("1N4148", netlist.Elements[0].ModelName); + } + + [Test] + public void Convert_SingleDiode_SaturationCurrentParameterIsSet() + { + CreateDiodeDefinition("diode_1n4148"); + var board = new BoardState(10, 10); + var netA = board.CreateNet("ANODE"); + var netK = board.CreateNet("CATHODE"); + PlaceConnected2Pin(board, "diode_1n4148", 0, 0, netA.NetId, netK.NetId); + + var netlist = _converter.Convert(board); + + var elem = netlist.Elements[0]; + Assert.IsTrue(elem.Parameters.ContainsKey("Is"), "Should have Is parameter"); + Assert.AreEqual(1e-9, elem.Parameters["Is"], 1e-18); + } + + [Test] + public void Convert_SingleDiode_EmissionCoefficientParameterIsSet() + { + CreateDiodeDefinition("diode_1n4148"); + var board = new BoardState(10, 10); + var netA = board.CreateNet("ANODE"); + var netK = board.CreateNet("CATHODE"); + PlaceConnected2Pin(board, "diode_1n4148", 0, 0, netA.NetId, netK.NetId); + + var netlist = _converter.Convert(board); + + var elem = netlist.Elements[0]; + Assert.IsTrue(elem.Parameters.ContainsKey("N"), "Should have N parameter"); + Assert.AreEqual(1.8, elem.Parameters["N"], 1e-6); + } + + // ════════════════════════════════════════════════════════════════════════ + // LED CONVERSION + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_SingleLED_ElementIdUsesDPrefix() + { + CreateDiodeDefinition("led_red", ComponentKind.LED); + var board = new BoardState(10, 10); + var netA = board.CreateNet("ANODE"); + var netK = board.CreateNet("CATHODE"); + var comp = PlaceConnected2Pin(board, "led_red", 0, 0, netA.NetId, netK.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual($"D{comp.InstanceId}", netlist.Elements[0].Id); + } + + [Test] + public void Convert_SingleLED_ElementTypeIsDiode() + { + CreateDiodeDefinition("led_red", ComponentKind.LED); + var board = new BoardState(10, 10); + var netA = board.CreateNet("ANODE"); + var netK = board.CreateNet("CATHODE"); + PlaceConnected2Pin(board, "led_red", 0, 0, netA.NetId, netK.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(ElementType.Diode, netlist.Elements[0].Type); + } + + // ════════════════════════════════════════════════════════════════════════ + // ZENER DIODE CONVERSION + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_SingleZenerDiode_ElementIdUsesDPrefix() + { + CreateZenerDefinition("zener_5v1"); + var board = new BoardState(10, 10); + var netA = board.CreateNet("ANODE"); + var netK = board.CreateNet("CATHODE"); + var comp = PlaceConnected2Pin(board, "zener_5v1", 0, 0, netA.NetId, netK.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual($"D{comp.InstanceId}", netlist.Elements[0].Id); + } + + [Test] + public void Convert_SingleZenerDiode_ElementTypeIsDiode() + { + CreateZenerDefinition("zener_5v1"); + var board = new BoardState(10, 10); + var netA = board.CreateNet("ANODE"); + var netK = board.CreateNet("CATHODE"); + PlaceConnected2Pin(board, "zener_5v1", 0, 0, netA.NetId, netK.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(ElementType.Diode, netlist.Elements[0].Type); + } + + [Test] + public void Convert_SingleZenerDiode_BreakdownVoltageParameterIsSet() + { + CreateZenerDefinition("zener_5v1"); + var board = new BoardState(10, 10); + var netA = board.CreateNet("ANODE"); + var netK = board.CreateNet("CATHODE"); + PlaceConnected2Pin(board, "zener_5v1", 0, 0, netA.NetId, netK.NetId); + + var netlist = _converter.Convert(board); + + var elem = netlist.Elements[0]; + Assert.IsTrue(elem.Parameters.ContainsKey("BV"), "Should have BV parameter"); + Assert.AreEqual(5.1, elem.Parameters["BV"], 1e-4); + } + + [Test] + public void Convert_SingleZenerDiode_BreakdownCurrentParameterIsSet() + { + CreateZenerDefinition("zener_5v1"); + var board = new BoardState(10, 10); + var netA = board.CreateNet("ANODE"); + var netK = board.CreateNet("CATHODE"); + PlaceConnected2Pin(board, "zener_5v1", 0, 0, netA.NetId, netK.NetId); + + var netlist = _converter.Convert(board); + + var elem = netlist.Elements[0]; + Assert.IsTrue(elem.Parameters.ContainsKey("IBV"), "Should have IBV parameter"); + Assert.AreEqual(0.001, elem.Parameters["IBV"], 1e-9); + } + + // ════════════════════════════════════════════════════════════════════════ + // BJT CONVERSION + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_SingleNPNBJT_ElementIdUsesQPrefix() + { + CreateBJTDefinition("bjt_2n2222"); + var board = new BoardState(10, 10); + var netC = board.CreateNet("COLLECTOR"); + var netB = board.CreateNet("BASE"); + var netE = board.CreateNet("EMITTER"); + var comp = PlaceConnected3Pin(board, "bjt_2n2222", 0, 0, netC.NetId, netB.NetId, netE.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual($"Q{comp.InstanceId}", netlist.Elements[0].Id); + } + + [Test] + public void Convert_SingleNPNBJT_ElementTypeIsBJT() + { + CreateBJTDefinition("bjt_2n2222"); + var board = new BoardState(10, 10); + var netC = board.CreateNet("COLLECTOR"); + var netB = board.CreateNet("BASE"); + var netE = board.CreateNet("EMITTER"); + PlaceConnected3Pin(board, "bjt_2n2222", 0, 0, netC.NetId, netB.NetId, netE.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(ElementType.BJT, netlist.Elements[0].Type); + } + + [Test] + public void Convert_SingleNPNBJT_HasThreeNodes() + { + CreateBJTDefinition("bjt_2n2222"); + var board = new BoardState(10, 10); + var netC = board.CreateNet("COLLECTOR"); + var netB = board.CreateNet("BASE"); + var netE = board.CreateNet("EMITTER"); + PlaceConnected3Pin(board, "bjt_2n2222", 0, 0, netC.NetId, netB.NetId, netE.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(3, netlist.Elements[0].Nodes.Count); + } + + [Test] + public void Convert_SingleNPNBJT_ModelNameIsSet() + { + CreateBJTDefinition("bjt_2n2222"); + var board = new BoardState(10, 10); + var netC = board.CreateNet("COLLECTOR"); + var netB = board.CreateNet("BASE"); + var netE = board.CreateNet("EMITTER"); + PlaceConnected3Pin(board, "bjt_2n2222", 0, 0, netC.NetId, netB.NetId, netE.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual("2N2222", netlist.Elements[0].ModelName); + } + + [Test] + public void Convert_SingleNPNBJT_ValueIsPositiveForNPN() + { + CreateBJTDefinition("bjt_2n2222", BJTPolarity.NPN); + var board = new BoardState(10, 10); + var netC = board.CreateNet("COLLECTOR"); + var netB = board.CreateNet("BASE"); + var netE = board.CreateNet("EMITTER"); + PlaceConnected3Pin(board, "bjt_2n2222", 0, 0, netC.NetId, netB.NetId, netE.NetId); + + var netlist = _converter.Convert(board); + + // NPN → isNPN=true → Value=1 + Assert.AreEqual(1.0, netlist.Elements[0].Value, 1e-9); + } + + [Test] + public void Convert_SinglePNPBJT_ValueIsNegativeForPNP() + { + CreateBJTDefinition("bjt_pnp", BJTPolarity.PNP); + var board = new BoardState(10, 10); + var netC = board.CreateNet("COLLECTOR"); + var netB = board.CreateNet("BASE"); + var netE = board.CreateNet("EMITTER"); + PlaceConnected3Pin(board, "bjt_pnp", 0, 0, netC.NetId, netB.NetId, netE.NetId); + + var netlist = _converter.Convert(board); + + // PNP → isNPN=false → Value=-1 + Assert.AreEqual(-1.0, netlist.Elements[0].Value, 1e-9); + } + + [Test] + public void Convert_SingleNPNBJT_BetaParameterIsSet() + { + CreateBJTDefinition("bjt_2n2222"); + var board = new BoardState(10, 10); + var netC = board.CreateNet("COLLECTOR"); + var netB = board.CreateNet("BASE"); + var netE = board.CreateNet("EMITTER"); + PlaceConnected3Pin(board, "bjt_2n2222", 0, 0, netC.NetId, netB.NetId, netE.NetId); + + var netlist = _converter.Convert(board); + + var elem = netlist.Elements[0]; + Assert.IsTrue(elem.Parameters.ContainsKey("Bf"), "Should have Bf (Beta) parameter"); + Assert.AreEqual(200.0, elem.Parameters["Bf"], 1e-6); + } + + [Test] + public void Convert_SingleNPNBJT_EarlyVoltageParameterIsSet() + { + CreateBJTDefinition("bjt_2n2222"); + var board = new BoardState(10, 10); + var netC = board.CreateNet("COLLECTOR"); + var netB = board.CreateNet("BASE"); + var netE = board.CreateNet("EMITTER"); + PlaceConnected3Pin(board, "bjt_2n2222", 0, 0, netC.NetId, netB.NetId, netE.NetId); + + var netlist = _converter.Convert(board); + + var elem = netlist.Elements[0]; + Assert.IsTrue(elem.Parameters.ContainsKey("Vaf"), "Should have Vaf (Early voltage) parameter"); + Assert.AreEqual(100.0, elem.Parameters["Vaf"], 1e-6); + } + + // ════════════════════════════════════════════════════════════════════════ + // MOSFET CONVERSION + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_SingleNChannelMOSFET_ElementIdUsesMPrefix() + { + CreateMOSFETDefinition("mosfet_2n7000"); + var board = new BoardState(10, 10); + var netD = board.CreateNet("DRAIN"); + var netG = board.CreateNet("GATE"); + var netS = board.CreateNet("SOURCE"); + var comp = PlaceConnected3Pin(board, "mosfet_2n7000", 0, 0, netD.NetId, netG.NetId, netS.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual($"M{comp.InstanceId}", netlist.Elements[0].Id); + } + + [Test] + public void Convert_SingleNChannelMOSFET_ElementTypeIsMOSFET() + { + CreateMOSFETDefinition("mosfet_2n7000"); + var board = new BoardState(10, 10); + var netD = board.CreateNet("DRAIN"); + var netG = board.CreateNet("GATE"); + var netS = board.CreateNet("SOURCE"); + PlaceConnected3Pin(board, "mosfet_2n7000", 0, 0, netD.NetId, netG.NetId, netS.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(ElementType.MOSFET, netlist.Elements[0].Type); + } + + [Test] + public void Convert_SingleNChannelMOSFET_HasFourNodes() + { + CreateMOSFETDefinition("mosfet_2n7000"); + var board = new BoardState(10, 10); + var netD = board.CreateNet("DRAIN"); + var netG = board.CreateNet("GATE"); + var netS = board.CreateNet("SOURCE"); + PlaceConnected3Pin(board, "mosfet_2n7000", 0, 0, netD.NetId, netG.NetId, netS.NetId); + + var netlist = _converter.Convert(board); + + // Drain, Gate, Source, Bulk (auto-tied to Source) + Assert.AreEqual(4, netlist.Elements[0].Nodes.Count); + } + + [Test] + public void Convert_SingleNChannelMOSFET_BulkTiedToSource() + { + CreateMOSFETDefinition("mosfet_2n7000"); + var board = new BoardState(10, 10); + var netD = board.CreateNet("DRAIN"); + var netG = board.CreateNet("GATE"); + var netS = board.CreateNet("SOURCE"); + PlaceConnected3Pin(board, "mosfet_2n7000", 0, 0, netD.NetId, netG.NetId, netS.NetId); + + var netlist = _converter.Convert(board); + + var elem = netlist.Elements[0]; + // Nodes[2]=Source, Nodes[3]=Bulk, they must be equal + Assert.AreEqual(elem.Nodes[2], elem.Nodes[3], + "Bulk node should be tied to Source node"); + Assert.AreEqual("SOURCE", elem.Nodes[3]); + } + + [Test] + public void Convert_SingleNChannelMOSFET_ModelNameIsSet() + { + CreateMOSFETDefinition("mosfet_2n7000"); + var board = new BoardState(10, 10); + var netD = board.CreateNet("DRAIN"); + var netG = board.CreateNet("GATE"); + var netS = board.CreateNet("SOURCE"); + PlaceConnected3Pin(board, "mosfet_2n7000", 0, 0, netD.NetId, netG.NetId, netS.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual("2N7000", netlist.Elements[0].ModelName); + } + + [Test] + public void Convert_SingleNChannelMOSFET_ValueIsPositiveForNChannel() + { + CreateMOSFETDefinition("mosfet_2n7000", FETPolarity.NChannel); + var board = new BoardState(10, 10); + var netD = board.CreateNet("DRAIN"); + var netG = board.CreateNet("GATE"); + var netS = board.CreateNet("SOURCE"); + PlaceConnected3Pin(board, "mosfet_2n7000", 0, 0, netD.NetId, netG.NetId, netS.NetId); + + var netlist = _converter.Convert(board); + + // NChannel → isNChannel=true → Value=1 + Assert.AreEqual(1.0, netlist.Elements[0].Value, 1e-9); + } + + [Test] + public void Convert_SinglePChannelMOSFET_ValueIsNegativeForPChannel() + { + CreateMOSFETDefinition("mosfet_pch", FETPolarity.PChannel); + var board = new BoardState(10, 10); + var netD = board.CreateNet("DRAIN"); + var netG = board.CreateNet("GATE"); + var netS = board.CreateNet("SOURCE"); + PlaceConnected3Pin(board, "mosfet_pch", 0, 0, netD.NetId, netG.NetId, netS.NetId); + + var netlist = _converter.Convert(board); + + // PChannel → isNChannel=false → Value=-1 + Assert.AreEqual(-1.0, netlist.Elements[0].Value, 1e-9); + } + + [Test] + public void Convert_SingleNChannelMOSFET_ThresholdVoltageParameterIsSet() + { + CreateMOSFETDefinition("mosfet_2n7000"); + var board = new BoardState(10, 10); + var netD = board.CreateNet("DRAIN"); + var netG = board.CreateNet("GATE"); + var netS = board.CreateNet("SOURCE"); + PlaceConnected3Pin(board, "mosfet_2n7000", 0, 0, netD.NetId, netG.NetId, netS.NetId); + + var netlist = _converter.Convert(board); + + var elem = netlist.Elements[0]; + Assert.IsTrue(elem.Parameters.ContainsKey("Vto"), "Should have Vto parameter"); + Assert.AreEqual(2.0, elem.Parameters["Vto"], 1e-6); + } + + [Test] + public void Convert_SingleNChannelMOSFET_TransconductanceParameterIsSet() + { + CreateMOSFETDefinition("mosfet_2n7000"); + var board = new BoardState(10, 10); + var netD = board.CreateNet("DRAIN"); + var netG = board.CreateNet("GATE"); + var netS = board.CreateNet("SOURCE"); + PlaceConnected3Pin(board, "mosfet_2n7000", 0, 0, netD.NetId, netG.NetId, netS.NetId); + + var netlist = _converter.Convert(board); + + var elem = netlist.Elements[0]; + Assert.IsTrue(elem.Parameters.ContainsKey("Kp"), "Should have Kp parameter"); + Assert.AreEqual(0.3, elem.Parameters["Kp"], 1e-6); + } + + // ════════════════════════════════════════════════════════════════════════ + // GROUND COMPONENT + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_GroundComponent_ReturnsNoElements() + { + CreateDefinition("ground", ComponentKind.Ground); + var board = new BoardState(10, 10); + board.PlaceComponent("ground", new GridPosition(0, 0), 0, CreatePins(1)); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(0, netlist.Elements.Count, + "Ground component should not generate a netlist element"); + } + + // ════════════════════════════════════════════════════════════════════════ + // PROBE COMPONENT + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_ProbeComponent_ReturnsNoElements() + { + CreateDefinition("probe", ComponentKind.Probe); + var board = new BoardState(10, 10); + board.PlaceComponent("probe", new GridPosition(0, 0), 0, CreatePins(1)); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(0, netlist.Elements.Count, + "Probe component should not generate a netlist element"); + } + + // ════════════════════════════════════════════════════════════════════════ + // UNCONNECTED / MISSING NET TESTS + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_UnconnectedPin_NodeNameUsesNCPattern() + { + CreateResistorDefinition("resistor_1k", 1000f); + var board = new BoardState(10, 10); + // Place component but don't connect any pins + var comp = board.PlaceComponent("resistor_1k", new GridPosition(0, 0), 0, CreatePins(2)); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(1, netlist.Elements.Count); + var elem = netlist.Elements[0]; + // Unconnected pins should produce NC_{instanceId}_{pinIndex} + Assert.AreEqual($"NC_{comp.InstanceId}_0", elem.Nodes[0]); + Assert.AreEqual($"NC_{comp.InstanceId}_1", elem.Nodes[1]); + } + + [Test] + public void Convert_PinConnectedToInvalidNet_NodeNameUsesNCPattern() + { + // We test the "net not found" path by directly manipulating + // This is harder to trigger because ConnectPinToNet validates netId. + // Instead, verify the unconnected path is consistent with the NC pattern. + CreateResistorDefinition("resistor_1k", 1000f); + var board = new BoardState(10, 10); + var net = board.CreateNet("NET_A"); + var comp = board.PlaceComponent("resistor_1k", new GridPosition(0, 0), 0, CreatePins(2)); + // Connect only pin 0 + board.ConnectPinToNet(net.NetId, + new PinReference(comp.InstanceId, 0, comp.GetPinWorldPosition(0))); + // Pin 1 stays unconnected + + var netlist = _converter.Convert(board); + + var elem = netlist.Elements[0]; + Assert.AreEqual("NET_A", elem.Nodes[0], "Pin 0 should map to NET_A"); + Assert.AreEqual($"NC_{comp.InstanceId}_1", elem.Nodes[1], + "Unconnected pin 1 should use NC pattern"); + } + + // ════════════════════════════════════════════════════════════════════════ + // PROBES PARAMETER + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_WithProbesParameter_ProbesAddedToNetlist() + { + var board = new BoardState(10, 10); + var probes = new[] + { + ProbeDefinition.Voltage("V_out", "OUT"), + ProbeDefinition.Voltage("V_in", "IN") + }; + + var netlist = _converter.Convert(board, probes); + + Assert.AreEqual(2, netlist.Probes.Count); + } + + [Test] + public void Convert_WithProbesParameter_ProbeIdAndTargetArePreserved() + { + var board = new BoardState(10, 10); + var probe = ProbeDefinition.Voltage("V_test", "TEST_NODE"); + + var netlist = _converter.Convert(board, new[] { probe }); + + Assert.AreEqual(1, netlist.Probes.Count); + Assert.AreEqual("V_test", netlist.Probes[0].Id); + Assert.AreEqual("TEST_NODE", netlist.Probes[0].Target); + } + + [Test] + public void Convert_NullProbesParameter_NoProbesAdded() + { + var board = new BoardState(10, 10); + + var netlist = _converter.Convert(board, null); + + Assert.AreEqual(0, netlist.Probes.Count); + } + + // ════════════════════════════════════════════════════════════════════════ + // CUSTOM VALUE OVERRIDE + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_ResistorWithCustomValue_OverridesDefinitionValue() + { + CreateResistorDefinition("resistor_1k", 1000f); + var board = new BoardState(10, 10); + var netA = board.CreateNet("NET_A"); + var netB = board.CreateNet("NET_B"); + // Place with customValue of 4700 (overrides 1000) + var comp = board.PlaceComponent("resistor_1k", new GridPosition(0, 0), 0, CreatePins(2), customValue: 4700f); + board.ConnectPinToNet(netA.NetId, new PinReference(comp.InstanceId, 0, comp.GetPinWorldPosition(0))); + board.ConnectPinToNet(netB.NetId, new PinReference(comp.InstanceId, 1, comp.GetPinWorldPosition(1))); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(4700.0, netlist.Elements[0].Value, 1e-9, + "CustomValue should override definition resistance"); + } + + [Test] + public void Convert_VoltageSourceWithCustomValue_OverridesDefinitionValue() + { + CreateVoltageSourceDefinition("vsource_5v", 5f); + var board = new BoardState(10, 10); + var netPos = board.CreateNet("VIN"); + var netNeg = board.CreateNet("GND"); + var comp = board.PlaceComponent("vsource_5v", new GridPosition(0, 0), 0, CreatePins(2), customValue: 12f); + board.ConnectPinToNet(netPos.NetId, new PinReference(comp.InstanceId, 0, comp.GetPinWorldPosition(0))); + board.ConnectPinToNet(netNeg.NetId, new PinReference(comp.InstanceId, 1, comp.GetPinWorldPosition(1))); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(12.0, netlist.Elements[0].Value, 1e-9, + "CustomValue should override definition voltage"); + } + + // ════════════════════════════════════════════════════════════════════════ + // MULTI-COMPONENT CIRCUIT + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_MultiComponentCircuit_AllElementsPresent() + { + CreateVoltageSourceDefinition("vsource", 5f); + CreateResistorDefinition("resistor_1k", 1000f); + CreateResistorDefinition("resistor_2k", 2000f); + + var board = new BoardState(20, 20); + var netVin = board.CreateNet("VIN"); + var netMid = board.CreateNet("MID"); + var netGnd = board.CreateNet("GND"); + + PlaceConnected2Pin(board, "vsource", 0, 0, netVin.NetId, netGnd.NetId); + PlaceConnected2Pin(board, "resistor_1k", 2, 0, netVin.NetId, netMid.NetId); + PlaceConnected2Pin(board, "resistor_2k", 4, 0, netMid.NetId, netGnd.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual(3, netlist.Elements.Count); + } + + [Test] + public void Convert_MultiComponentCircuit_ElementTypesAreCorrect() + { + CreateVoltageSourceDefinition("vsource", 5f); + CreateResistorDefinition("resistor_1k", 1000f); + + var board = new BoardState(20, 20); + var netVin = board.CreateNet("VIN"); + var netOut = board.CreateNet("OUT"); + var netGnd = board.CreateNet("GND"); + + PlaceConnected2Pin(board, "vsource", 0, 0, netVin.NetId, netGnd.NetId); + PlaceConnected2Pin(board, "resistor_1k", 2, 0, netVin.NetId, netOut.NetId); + + var netlist = _converter.Convert(board); + + var types = netlist.Elements.Select(e => e.Type).ToList(); + Assert.Contains(ElementType.VoltageSource, types); + Assert.Contains(ElementType.Resistor, types); + } + + // ════════════════════════════════════════════════════════════════════════ + // MISSING COMPONENT DEFINITION + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_ComponentDefinitionNotFound_ThrowsInvalidOperationException() + { + // Place a component whose def ID has no registered definition + var board = new BoardState(10, 10); + board.PlaceComponent("unknown_component", new GridPosition(0, 0), 0, CreatePins(2)); + + Assert.Throws(() => _converter.Convert(board)); + } + + // ════════════════════════════════════════════════════════════════════════ + // GetElementPrefix STATIC TESTS + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void GetElementPrefix_Resistor_ReturnsR() + { + Assert.AreEqual("R", BoardToNetlistConverter.GetElementPrefix(ComponentKind.Resistor)); + } + + [Test] + public void GetElementPrefix_Capacitor_ReturnsC() + { + Assert.AreEqual("C", BoardToNetlistConverter.GetElementPrefix(ComponentKind.Capacitor)); + } + + [Test] + public void GetElementPrefix_Inductor_ReturnsL() + { + Assert.AreEqual("L", BoardToNetlistConverter.GetElementPrefix(ComponentKind.Inductor)); + } + + [Test] + public void GetElementPrefix_VoltageSource_ReturnsV() + { + Assert.AreEqual("V", BoardToNetlistConverter.GetElementPrefix(ComponentKind.VoltageSource)); + } + + [Test] + public void GetElementPrefix_CurrentSource_ReturnsI() + { + Assert.AreEqual("I", BoardToNetlistConverter.GetElementPrefix(ComponentKind.CurrentSource)); + } + + [Test] + public void GetElementPrefix_Diode_ReturnsD() + { + Assert.AreEqual("D", BoardToNetlistConverter.GetElementPrefix(ComponentKind.Diode)); + } + + [Test] + public void GetElementPrefix_LED_ReturnsD() + { + Assert.AreEqual("D", BoardToNetlistConverter.GetElementPrefix(ComponentKind.LED)); + } + + [Test] + public void GetElementPrefix_ZenerDiode_ReturnsD() + { + Assert.AreEqual("D", BoardToNetlistConverter.GetElementPrefix(ComponentKind.ZenerDiode)); + } + + [Test] + public void GetElementPrefix_BJT_ReturnsQ() + { + Assert.AreEqual("Q", BoardToNetlistConverter.GetElementPrefix(ComponentKind.BJT)); + } + + [Test] + public void GetElementPrefix_MOSFET_ReturnsM() + { + Assert.AreEqual("M", BoardToNetlistConverter.GetElementPrefix(ComponentKind.MOSFET)); + } + + [Test] + public void GetElementPrefix_Ground_ReturnsX() + { + // Default case → X (unsupported/fallback prefix) + Assert.AreEqual("X", BoardToNetlistConverter.GetElementPrefix(ComponentKind.Ground)); + } + + [Test] + public void GetElementPrefix_Probe_ReturnsX() + { + Assert.AreEqual("X", BoardToNetlistConverter.GetElementPrefix(ComponentKind.Probe)); + } + + // ════════════════════════════════════════════════════════════════════════ + // MapComponentKind STATIC TESTS + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void MapComponentKind_Resistor_ReturnsResistor() + { + Assert.AreEqual(ElementType.Resistor, BoardToNetlistConverter.MapComponentKind(ComponentKind.Resistor)); + } + + [Test] + public void MapComponentKind_Capacitor_ReturnsCapacitor() + { + Assert.AreEqual(ElementType.Capacitor, BoardToNetlistConverter.MapComponentKind(ComponentKind.Capacitor)); + } + + [Test] + public void MapComponentKind_Inductor_ReturnsInductor() + { + Assert.AreEqual(ElementType.Inductor, BoardToNetlistConverter.MapComponentKind(ComponentKind.Inductor)); + } + + [Test] + public void MapComponentKind_VoltageSource_ReturnsVoltageSource() + { + Assert.AreEqual(ElementType.VoltageSource, BoardToNetlistConverter.MapComponentKind(ComponentKind.VoltageSource)); + } + + [Test] + public void MapComponentKind_CurrentSource_ReturnsCurrentSource() + { + Assert.AreEqual(ElementType.CurrentSource, BoardToNetlistConverter.MapComponentKind(ComponentKind.CurrentSource)); + } + + [Test] + public void MapComponentKind_Diode_ReturnsDiode() + { + Assert.AreEqual(ElementType.Diode, BoardToNetlistConverter.MapComponentKind(ComponentKind.Diode)); + } + + [Test] + public void MapComponentKind_LED_ReturnsDiode() + { + Assert.AreEqual(ElementType.Diode, BoardToNetlistConverter.MapComponentKind(ComponentKind.LED)); + } + + [Test] + public void MapComponentKind_ZenerDiode_ReturnsDiode() + { + Assert.AreEqual(ElementType.Diode, BoardToNetlistConverter.MapComponentKind(ComponentKind.ZenerDiode)); + } + + [Test] + public void MapComponentKind_BJT_ReturnsBJT() + { + Assert.AreEqual(ElementType.BJT, BoardToNetlistConverter.MapComponentKind(ComponentKind.BJT)); + } + + [Test] + public void MapComponentKind_MOSFET_ReturnsMOSFET() + { + Assert.AreEqual(ElementType.MOSFET, BoardToNetlistConverter.MapComponentKind(ComponentKind.MOSFET)); + } + + [Test] + public void MapComponentKind_Ground_ThrowsNotSupportedException() + { + Assert.Throws( + () => BoardToNetlistConverter.MapComponentKind(ComponentKind.Ground)); + } + + [Test] + public void MapComponentKind_Probe_ThrowsNotSupportedException() + { + Assert.Throws( + () => BoardToNetlistConverter.MapComponentKind(ComponentKind.Probe)); + } + + // ════════════════════════════════════════════════════════════════════════ + // BOARD IDs AUTO-INCREMENT FROM 1 + // ════════════════════════════════════════════════════════════════════════ + + [Test] + public void Convert_FirstPlacedComponent_InstanceIdIsOne() + { + CreateResistorDefinition("resistor_1k", 1000f); + var board = new BoardState(10, 10); + var comp = board.PlaceComponent("resistor_1k", new GridPosition(0, 0), 0, CreatePins(2)); + + Assert.AreEqual(1, comp.InstanceId, "First placed component should have InstanceId=1"); + } + + [Test] + public void Convert_FirstResistor_ElementIdIsR1() + { + CreateResistorDefinition("resistor_1k", 1000f); + var board = new BoardState(10, 10); + var netA = board.CreateNet("NET_A"); + var netB = board.CreateNet("NET_B"); + PlaceConnected2Pin(board, "resistor_1k", 0, 0, netA.NetId, netB.NetId); + + var netlist = _converter.Convert(board); + + Assert.AreEqual("R1", netlist.Elements[0].Id); + } + } +} diff --git a/Assets/10_Scripts/30_Tests/20_Systems/BoardToNetlistConverterTests.cs.meta b/Assets/10_Scripts/30_Tests/20_Systems/BoardToNetlistConverterTests.cs.meta new file mode 100644 index 0000000..dcfa975 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/20_Systems/BoardToNetlistConverterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d01d7aaf6b7c5e46b04121a3249685f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/20_Systems/SaveLoadServiceTests.cs b/Assets/10_Scripts/30_Tests/20_Systems/SaveLoadServiceTests.cs new file mode 100644 index 0000000..7c4abd8 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/20_Systems/SaveLoadServiceTests.cs @@ -0,0 +1,664 @@ +using System; +using System.Collections.Generic; +using System.IO; +using NUnit.Framework; +using CircuitCraft.Core; +using CircuitCraft.Systems; + +namespace CircuitCraft.Tests.Systems +{ + [TestFixture] + public class SaveLoadServiceTests + { + private SaveLoadService _service; + private string _tempDirectory; + + // ── Setup / Teardown ──────────────────────────────────────────────────── + + [SetUp] + public void SetUp() + { + _service = new SaveLoadService(); + _tempDirectory = Path.Combine(Path.GetTempPath(), "circuitcraft_test_" + Guid.NewGuid()); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempDirectory)) + Directory.Delete(_tempDirectory, recursive: true); + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + /// Creates a list of PinInstances with staggered horizontal local positions. + private static List CreatePins(int count) + { + var pins = new List(); + for (int i = 0; i < count; i++) + pins.Add(new PinInstance(i, $"pin{i}", new GridPosition(i, 0))); + return pins; + } + + /// + /// Builds a small board: one resistor at (0,0) and one voltage source at (5,0), + /// one net "VIN", traces, and pin connections. + /// + private BoardState BuildFullBoard() + { + var board = new BoardState(20, 20); + + // Component 1: resistor at (0,0) with 2 pins + var resPins = CreatePins(2); + var resistor = board.PlaceComponent("resistor_1k", new GridPosition(0, 0), 0, resPins); + + // Component 2: vsource at (5,0) with 2 pins + var vsPins = CreatePins(2); + var vsource = board.PlaceComponent("vsource_5v", new GridPosition(5, 0), 0, vsPins); + + // Net + var net = board.CreateNet("VIN"); + + // Trace on the net + board.AddTrace(net.NetId, new GridPosition(1, 0), new GridPosition(5, 0)); + + // Pin connections + board.ConnectPinToNet(net.NetId, + new PinReference(resistor.InstanceId, 1, resistor.GetPinWorldPosition(1))); + board.ConnectPinToNet(net.NetId, + new PinReference(vsource.InstanceId, 0, vsource.GetPinWorldPosition(0))); + + return board; + } + + // ── Serialize: guard clauses ───────────────────────────────────────────── + + [Test] + public void Serialize_NullBoardState_ThrowsArgumentNullException() + { + Assert.Throws(() => _service.Serialize(null, "stage-1")); + } + + [Test] + public void Serialize_NullStageId_ThrowsArgumentException() + { + var board = new BoardState(10, 10); + Assert.Throws(() => _service.Serialize(board, null)); + } + + [Test] + public void Serialize_EmptyStageId_ThrowsArgumentException() + { + var board = new BoardState(10, 10); + Assert.Throws(() => _service.Serialize(board, " ")); + } + + // ── Deserialize: guard clauses ──────────────────────────────────────────── + + [Test] + public void Deserialize_NullJson_ThrowsArgumentException() + { + Assert.Throws(() => _service.Deserialize(null)); + } + + [Test] + public void Deserialize_EmptyJson_ThrowsArgumentException() + { + Assert.Throws(() => _service.Deserialize("")); + } + + [Test] + public void Deserialize_WhitespaceJson_ThrowsArgumentException() + { + Assert.Throws(() => _service.Deserialize(" ")); + } + + // ── Serialize: empty board ──────────────────────────────────────────────── + + [Test] + public void Serialize_EmptyBoard_ProducesValidJsonWithMetadata() + { + var board = new BoardState(15, 12); + var json = _service.Serialize(board, "stage-empty"); + + Assert.IsNotNull(json); + Assert.IsNotEmpty(json); + + var data = _service.Deserialize(json); + Assert.AreEqual("stage-empty", data.stageId); + Assert.AreEqual(15, data.boardWidth); + Assert.AreEqual(12, data.boardHeight); + Assert.IsNotNull(data.components); + Assert.AreEqual(0, data.components.Count); + Assert.IsNotNull(data.nets); + Assert.AreEqual(0, data.nets.Count); + Assert.IsNotNull(data.traces); + Assert.AreEqual(0, data.traces.Count); + Assert.IsNotNull(data.pinConnections); + Assert.AreEqual(0, data.pinConnections.Count); + } + + // ── Serialize: components ───────────────────────────────────────────────── + + [Test] + public void Serialize_BoardWithComponent_ComponentSaveDataHasCorrectProperties() + { + var board = new BoardState(10, 10); + var pins = CreatePins(2); + var comp = board.PlaceComponent("resistor_1k", new GridPosition(3, 4), 90, pins, isFixed: false); + + var json = _service.Serialize(board, "stage-1"); + var data = _service.Deserialize(json); + + Assert.AreEqual(1, data.components.Count); + var compData = data.components[0]; + Assert.AreEqual(comp.InstanceId, compData.instanceId); + Assert.AreEqual("resistor_1k", compData.componentDefId); + Assert.AreEqual(3, compData.positionX); + Assert.AreEqual(4, compData.positionY); + Assert.AreEqual(90, compData.rotation); + Assert.IsFalse(compData.isFixed); + Assert.IsFalse(compData.hasCustomValue); + } + + [Test] + public void Serialize_FixedComponent_IsFixedFlagPreserved() + { + var board = new BoardState(10, 10); + var pins = CreatePins(1); + board.PlaceComponent("ground", new GridPosition(0, 0), 0, pins, isFixed: true); + + var json = _service.Serialize(board, "stage-1"); + var data = _service.Deserialize(json); + + Assert.AreEqual(1, data.components.Count); + Assert.IsTrue(data.components[0].isFixed); + } + + [Test] + public void Serialize_ComponentPins_PinDataMatchesOriginal() + { + var board = new BoardState(10, 10); + var pins = CreatePins(2); + board.PlaceComponent("resistor_1k", new GridPosition(0, 0), 0, pins); + + var json = _service.Serialize(board, "stage-1"); + var data = _service.Deserialize(json); + + var compData = data.components[0]; + Assert.AreEqual(2, compData.pins.Count); + + Assert.AreEqual(0, compData.pins[0].pinIndex); + Assert.AreEqual("pin0", compData.pins[0].pinName); + Assert.AreEqual(0, compData.pins[0].localPositionX); + Assert.AreEqual(0, compData.pins[0].localPositionY); + + Assert.AreEqual(1, compData.pins[1].pinIndex); + Assert.AreEqual("pin1", compData.pins[1].pinName); + Assert.AreEqual(1, compData.pins[1].localPositionX); + Assert.AreEqual(0, compData.pins[1].localPositionY); + } + + // ── Serialize: custom value ─────────────────────────────────────────────── + + [Test] + public void Serialize_ComponentWithCustomValue_HasCustomValueTrueAndValueSet() + { + var board = new BoardState(10, 10); + var pins = CreatePins(2); + board.PlaceComponent("resistor_custom", new GridPosition(0, 0), 0, pins, customValue: 4700f); + + var json = _service.Serialize(board, "stage-1"); + var data = _service.Deserialize(json); + + var compData = data.components[0]; + Assert.IsTrue(compData.hasCustomValue); + Assert.AreEqual(4700f, compData.customValue, 0.001f); + } + + [Test] + public void Serialize_ComponentWithoutCustomValue_HasCustomValueFalse() + { + var board = new BoardState(10, 10); + var pins = CreatePins(2); + board.PlaceComponent("resistor_1k", new GridPosition(0, 0), 0, pins); + + var json = _service.Serialize(board, "stage-1"); + var data = _service.Deserialize(json); + + Assert.IsFalse(data.components[0].hasCustomValue); + } + + // ── Serialize: nets + pin connections ──────────────────────────────────── + + [Test] + public void Serialize_BoardWithNetAndPinConnections_NetAndConnectionDataPopulated() + { + var board = new BoardState(10, 10); + var pins = CreatePins(2); + var comp = board.PlaceComponent("resistor_1k", new GridPosition(0, 0), 0, pins); + + var net = board.CreateNet("VIN"); + board.ConnectPinToNet(net.NetId, + new PinReference(comp.InstanceId, 0, comp.GetPinWorldPosition(0))); + board.ConnectPinToNet(net.NetId, + new PinReference(comp.InstanceId, 1, comp.GetPinWorldPosition(1))); + + var json = _service.Serialize(board, "stage-1"); + var data = _service.Deserialize(json); + + Assert.AreEqual(1, data.nets.Count); + Assert.AreEqual(net.NetId, data.nets[0].netId); + Assert.AreEqual("VIN", data.nets[0].netName); + + Assert.AreEqual(2, data.pinConnections.Count); + + // Both connections reference the correct component and net + foreach (var conn in data.pinConnections) + { + Assert.AreEqual(comp.InstanceId, conn.componentInstanceId); + Assert.AreEqual(net.NetId, conn.netId); + } + } + + // ── Serialize: traces ───────────────────────────────────────────────────── + + [Test] + public void Serialize_BoardWithTraces_TraceSaveDataHasCorrectNetIdAndPositions() + { + var board = new BoardState(20, 20); + var net = board.CreateNet("NET1"); + board.AddTrace(net.NetId, new GridPosition(1, 2), new GridPosition(5, 2)); + + var json = _service.Serialize(board, "stage-1"); + var data = _service.Deserialize(json); + + Assert.AreEqual(1, data.traces.Count); + var traceData = data.traces[0]; + Assert.AreEqual(net.NetId, traceData.netId); + Assert.AreEqual(1, traceData.startX); + Assert.AreEqual(2, traceData.startY); + Assert.AreEqual(5, traceData.endX); + Assert.AreEqual(2, traceData.endY); + } + + // ── RestoreToBoard: guard clauses ───────────────────────────────────────── + + [Test] + public void RestoreToBoard_NullBoardState_ThrowsArgumentNullException() + { + var data = new BoardSaveData { stageId = "s1", boardWidth = 10, boardHeight = 10 }; + Assert.Throws(() => _service.RestoreToBoard(null, data)); + } + + [Test] + public void RestoreToBoard_NullData_ThrowsArgumentNullException() + { + var board = new BoardState(10, 10); + Assert.Throws(() => _service.RestoreToBoard(board, null)); + } + + // ── Round-trip: simple board ────────────────────────────────────────────── + + [Test] + public void RoundTrip_EmptyBoard_RestoredBoardIsAlsoEmpty() + { + var original = new BoardState(10, 10); + + var json = _service.Serialize(original, "stage-rt"); + var saveData = _service.Deserialize(json); + var restored = new BoardState(10, 10); + _service.RestoreToBoard(restored, saveData); + + Assert.AreEqual(0, restored.Components.Count); + Assert.AreEqual(0, restored.Nets.Count); + Assert.AreEqual(0, restored.Traces.Count); + } + + [Test] + public void RoundTrip_SingleComponent_ComponentPropertiesMatchAfterRestore() + { + var original = new BoardState(10, 10); + var pins = CreatePins(2); + original.PlaceComponent("resistor_1k", new GridPosition(3, 4), 90, pins, customValue: 2200f); + + var json = _service.Serialize(original, "stage-rt"); + var saveData = _service.Deserialize(json); + var restored = new BoardState(10, 10); + _service.RestoreToBoard(restored, saveData); + + Assert.AreEqual(1, restored.Components.Count); + var comp = restored.Components[0]; + Assert.AreEqual("resistor_1k", comp.ComponentDefinitionId); + Assert.AreEqual(3, comp.Position.X); + Assert.AreEqual(4, comp.Position.Y); + Assert.AreEqual(90, comp.Rotation); + Assert.IsTrue(comp.CustomValue.HasValue); + Assert.AreEqual(2200f, comp.CustomValue.Value, 0.001f); + Assert.AreEqual(2, comp.Pins.Count); + } + + [Test] + public void RoundTrip_FixedComponent_IsFixedPreservedAfterRestore() + { + var original = new BoardState(10, 10); + var pins = CreatePins(1); + original.PlaceComponent("ground", new GridPosition(0, 0), 0, pins, isFixed: true); + + var json = _service.Serialize(original, "stage-rt"); + var saveData = _service.Deserialize(json); + var restored = new BoardState(10, 10); + _service.RestoreToBoard(restored, saveData); + + Assert.AreEqual(1, restored.Components.Count); + Assert.IsTrue(restored.Components[0].IsFixed); + } + + [Test] + public void RoundTrip_ComponentsNetsTracesAndConnections_StateMatchesOriginal() + { + var original = BuildFullBoard(); + + var json = _service.Serialize(original, "stage-full"); + var saveData = _service.Deserialize(json); + var restored = new BoardState(20, 20); + _service.RestoreToBoard(restored, saveData); + + // Same counts + Assert.AreEqual(original.Components.Count, restored.Components.Count); + Assert.AreEqual(original.Nets.Count, restored.Nets.Count); + Assert.AreEqual(original.Traces.Count, restored.Traces.Count); + } + + [Test] + public void RoundTrip_NetName_PreservedAfterRestore() + { + var original = new BoardState(10, 10); + original.CreateNet("GND"); + original.CreateNet("VIN"); + + var json = _service.Serialize(original, "stage-1"); + var saveData = _service.Deserialize(json); + var restored = new BoardState(10, 10); + _service.RestoreToBoard(restored, saveData); + + Assert.AreEqual(2, restored.Nets.Count); + + var gnd = restored.GetNetByName("GND"); + var vin = restored.GetNetByName("VIN"); + Assert.IsNotNull(gnd, "GND net should be restored"); + Assert.IsNotNull(vin, "VIN net should be restored"); + } + + [Test] + public void RoundTrip_TracePositions_PreservedAfterRestore() + { + var original = new BoardState(20, 20); + var net = original.CreateNet("NET1"); + original.AddTrace(net.NetId, new GridPosition(0, 5), new GridPosition(10, 5)); + + var json = _service.Serialize(original, "stage-1"); + var saveData = _service.Deserialize(json); + var restored = new BoardState(20, 20); + _service.RestoreToBoard(restored, saveData); + + Assert.AreEqual(1, restored.Traces.Count); + var trace = restored.Traces[0]; + Assert.AreEqual(0, trace.Start.X); + Assert.AreEqual(5, trace.Start.Y); + Assert.AreEqual(10, trace.End.X); + Assert.AreEqual(5, trace.End.Y); + } + + // ── RestoreToBoard: ID remapping ────────────────────────────────────────── + + [Test] + public void RestoreToBoard_IdRemapping_PinConnectionsReferenceNewIds() + { + // Build original board with component + net + connection + var original = new BoardState(10, 10); + var pins = CreatePins(2); + var comp = original.PlaceComponent("resistor_1k", new GridPosition(0, 0), 0, pins); + var net = original.CreateNet("VIN"); + board_ConnectPin(original, comp, 0, net.NetId); + board_ConnectPin(original, comp, 1, net.NetId); + + // Verify original IDs (auto-increment from 1) + Assert.AreEqual(1, comp.InstanceId); + Assert.AreEqual(1, net.NetId); + + // Serialize and restore into a fresh board + var json = _service.Serialize(original, "stage-remap"); + var saveData = _service.Deserialize(json); + var restored = new BoardState(10, 10); + _service.RestoreToBoard(restored, saveData); + + // Restored component and net should also have valid IDs (likely 1 again) + Assert.AreEqual(1, restored.Components.Count); + Assert.AreEqual(1, restored.Nets.Count); + + var restoredComp = restored.Components[0]; + var restoredNet = restored.Nets[0]; + + // Pin connections should reference the RESTORED (new) component and net IDs + Assert.AreEqual(2, restoredNet.ConnectedPins.Count, + "Both pins should be connected to the restored net"); + + foreach (var pinRef in restoredNet.ConnectedPins) + { + Assert.AreEqual(restoredComp.InstanceId, pinRef.ComponentInstanceId, + "PinReference should use restored component ID, not original saved ID"); + } + } + + // Helper to avoid tuple usage when connecting pins + private static void board_ConnectPin(BoardState board, PlacedComponent comp, int pinIndex, int netId) + { + board.ConnectPinToNet(netId, + new PinReference(comp.InstanceId, pinIndex, comp.GetPinWorldPosition(pinIndex))); + } + + // ── ConvertToSaveData: custom value captured ────────────────────────────── + + [Test] + public void Serialize_CustomValueZero_HasCustomValueTrueAndValueIsZero() + { + // Explicitly passing 0f as custom value is a valid override + var board = new BoardState(10, 10); + var pins = CreatePins(2); + board.PlaceComponent("capacitor", new GridPosition(0, 0), 0, pins, customValue: 0f); + + var json = _service.Serialize(board, "stage-1"); + var data = _service.Deserialize(json); + + Assert.IsTrue(data.components[0].hasCustomValue); + Assert.AreEqual(0f, data.components[0].customValue, 0.001f); + } + + [Test] + public void Serialize_MultipleComponents_AllCapturedInOrder() + { + var board = new BoardState(20, 20); + board.PlaceComponent("resistor_1k", new GridPosition(0, 0), 0, CreatePins(2)); + board.PlaceComponent("vsource_5v", new GridPosition(5, 0), 0, CreatePins(2)); + board.PlaceComponent("ground", new GridPosition(10, 0), 0, CreatePins(1)); + + var json = _service.Serialize(board, "stage-1"); + var data = _service.Deserialize(json); + + Assert.AreEqual(3, data.components.Count); + Assert.AreEqual("resistor_1k", data.components[0].componentDefId); + Assert.AreEqual("vsource_5v", data.components[1].componentDefId); + Assert.AreEqual("ground", data.components[2].componentDefId); + } + + // ── File I/O: SaveToFile / LoadFromFile ─────────────────────────────────── + + [Test] + public void SaveToFile_NullPath_ThrowsArgumentException() + { + var board = new BoardState(10, 10); + Assert.Throws(() => _service.SaveToFile(null, board, "stage-1")); + } + + [Test] + public void SaveToFile_EmptyPath_ThrowsArgumentException() + { + var board = new BoardState(10, 10); + Assert.Throws(() => _service.SaveToFile("", board, "stage-1")); + } + + [Test] + public void LoadFromFile_NonExistentPath_ThrowsFileNotFoundException() + { + var fakePath = Path.Combine(_tempDirectory, "does_not_exist.json"); + Assert.Throws(() => _service.LoadFromFile(fakePath)); + } + + [Test] + public void LoadFromFile_EmptyPath_ThrowsArgumentException() + { + Assert.Throws(() => _service.LoadFromFile("")); + } + + [Test] + public void SaveToFile_ValidPath_CreatesFileWithJsonContent() + { + var board = new BoardState(10, 10); + board.PlaceComponent("resistor_1k", new GridPosition(0, 0), 0, CreatePins(2)); + + var filePath = Path.Combine(_tempDirectory, "save.json"); + _service.SaveToFile(filePath, board, "stage-file"); + + Assert.IsTrue(File.Exists(filePath), "Save file should exist on disk"); + var rawContent = File.ReadAllText(filePath); + Assert.IsNotEmpty(rawContent); + // Sanity: the JSON should at least contain the stageId + StringAssert.Contains("stage-file", rawContent); + } + + [Test] + public void SaveToFile_DirectoryDoesNotExist_CreatesDirectoryAndFile() + { + var board = new BoardState(10, 10); + var nestedDir = Path.Combine(_tempDirectory, "sub", "nested"); + var filePath = Path.Combine(nestedDir, "save.json"); + + _service.SaveToFile(filePath, board, "stage-nested"); + + Assert.IsTrue(File.Exists(filePath), "File should be created even with nested non-existent directories"); + } + + [Test] + public void FileRoundTrip_SaveThenLoad_DataMatchesOriginal() + { + var original = new BoardState(10, 10); + original.PlaceComponent("resistor_1k", new GridPosition(2, 3), 180, CreatePins(2), customValue: 1000f); + var net = original.CreateNet("VCC"); + original.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(2, 0)); + + var filePath = Path.Combine(_tempDirectory, "test_save.json"); + _service.SaveToFile(filePath, original, "stage-io"); + + var loadedData = _service.LoadFromFile(filePath); + + Assert.AreEqual("stage-io", loadedData.stageId); + Assert.AreEqual(10, loadedData.boardWidth); + Assert.AreEqual(10, loadedData.boardHeight); + Assert.AreEqual(1, loadedData.components.Count); + Assert.AreEqual("resistor_1k", loadedData.components[0].componentDefId); + Assert.AreEqual(2, loadedData.components[0].positionX); + Assert.AreEqual(3, loadedData.components[0].positionY); + Assert.AreEqual(180, loadedData.components[0].rotation); + Assert.IsTrue(loadedData.components[0].hasCustomValue); + Assert.AreEqual(1000f, loadedData.components[0].customValue, 0.001f); + Assert.AreEqual(1, loadedData.nets.Count); + Assert.AreEqual("VCC", loadedData.nets[0].netName); + Assert.AreEqual(1, loadedData.traces.Count); + } + + [Test] + public void FileRoundTrip_SaveLoadRestoreToBoard_StateMatchesOriginal() + { + var original = BuildFullBoard(); + var filePath = Path.Combine(_tempDirectory, "full_round_trip.json"); + + _service.SaveToFile(filePath, original, "stage-full-rt"); + var loadedData = _service.LoadFromFile(filePath); + var restored = new BoardState(20, 20); + _service.RestoreToBoard(restored, loadedData); + + Assert.AreEqual(original.Components.Count, restored.Components.Count); + Assert.AreEqual(original.Nets.Count, restored.Nets.Count); + Assert.AreEqual(original.Traces.Count, restored.Traces.Count); + } + + // ── Multiple traces on one net ──────────────────────────────────────────── + + [Test] + public void Serialize_MultipleTracesOnOneNet_AllTracesSerializedWithCorrectNetId() + { + var board = new BoardState(20, 20); + var net = board.CreateNet("NET_MULTI"); + board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + board.AddTrace(net.NetId, new GridPosition(5, 0), new GridPosition(5, 5)); + + var json = _service.Serialize(board, "stage-1"); + var data = _service.Deserialize(json); + + Assert.AreEqual(2, data.traces.Count); + foreach (var trace in data.traces) + Assert.AreEqual(net.NetId, trace.netId); + } + + // ── Multiple nets ───────────────────────────────────────────────────────── + + [Test] + public void Serialize_MultipleNets_AllNetsSerializedWithDistinctIds() + { + var board = new BoardState(20, 20); + var net1 = board.CreateNet("VIN"); + var net2 = board.CreateNet("GND"); + board.AddTrace(net1.NetId, new GridPosition(0, 0), new GridPosition(3, 0)); + board.AddTrace(net2.NetId, new GridPosition(5, 0), new GridPosition(8, 0)); + + var json = _service.Serialize(board, "stage-1"); + var data = _service.Deserialize(json); + + Assert.AreEqual(2, data.nets.Count); + Assert.AreNotEqual(data.nets[0].netId, data.nets[1].netId); + } + + // ── Round-trip: pin connection world position remapped from restored component ── + + [Test] + public void RoundTrip_PinWorldPositionRecalculatedFromRestoredComponent() + { + // Pin world position must come from the restored component's GetPinWorldPosition, + // not from the serialized pinWorldX/Y in PinConnectionSaveData. + var original = new BoardState(10, 10); + var pins = CreatePins(2); + var comp = original.PlaceComponent("resistor_1k", new GridPosition(2, 3), 0, pins); + var net = original.CreateNet("VOUT"); + board_ConnectPin(original, comp, 0, net.NetId); + + var json = _service.Serialize(original, "stage-1"); + var saveData = _service.Deserialize(json); + var restored = new BoardState(10, 10); + _service.RestoreToBoard(restored, saveData); + + // Verify that the pin is actually connected in the restored board + var restoredComp = restored.Components[0]; + var restoredNet = restored.Nets[0]; + + Assert.AreEqual(1, restoredNet.ConnectedPins.Count); + Assert.AreEqual(restoredComp.InstanceId, restoredNet.ConnectedPins[0].ComponentInstanceId); + Assert.AreEqual(0, restoredNet.ConnectedPins[0].PinIndex); + + // The stored world position should match what GetPinWorldPosition returns + var expectedWorldPos = restoredComp.GetPinWorldPosition(0); + Assert.AreEqual(expectedWorldPos.X, restoredNet.ConnectedPins[0].Position.X); + Assert.AreEqual(expectedWorldPos.Y, restoredNet.ConnectedPins[0].Position.Y); + } + } +} diff --git a/Assets/10_Scripts/30_Tests/20_Systems/SaveLoadServiceTests.cs.meta b/Assets/10_Scripts/30_Tests/20_Systems/SaveLoadServiceTests.cs.meta new file mode 100644 index 0000000..99a53e8 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/20_Systems/SaveLoadServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9e2f75d77cd85a34fa6d10a9baea2816 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/30_Commands.meta b/Assets/10_Scripts/30_Tests/30_Commands.meta new file mode 100644 index 0000000..0a021a6 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/30_Commands.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 232cee17380a92344b0d07da030d09d8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/30_Commands/CommandHistoryTests.cs b/Assets/10_Scripts/30_Tests/30_Commands/CommandHistoryTests.cs new file mode 100644 index 0000000..0f522e3 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/30_Commands/CommandHistoryTests.cs @@ -0,0 +1,500 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +using CircuitCraft.Commands; + +namespace CircuitCraft.Tests.Commands +{ + /// + /// Characterization tests for CommandHistory — captures ALL current behavior as a safety net + /// before any refactoring. Tests must pass against the CURRENT implementation as-is. + /// + [TestFixture] + public class CommandHistoryTests + { + private CommandHistory _history; + + [SetUp] + public void SetUp() + { + _history = new CommandHistory(); + } + + #region Helper Types + + /// + /// Simple fake command that tracks execute/undo call counts. + /// + private class FakeCommand : ICommand + { + public int ExecuteCount { get; private set; } + public int UndoCount { get; private set; } + public string Description { get; } + + public FakeCommand(string description = "fake") + { + Description = description; + } + + public void Execute() => ExecuteCount++; + public void Undo() => UndoCount++; + } + + #endregion + + #region Initial State Tests + + [Test] + public void NewHistory_CanUndo_IsFalse() + { + Assert.IsFalse(_history.CanUndo); + } + + [Test] + public void NewHistory_CanRedo_IsFalse() + { + Assert.IsFalse(_history.CanRedo); + } + + #endregion + + #region ExecuteCommand Tests + + [Test] + public void ExecuteCommand_CallsCommandExecute() + { + var cmd = new FakeCommand(); + _history.ExecuteCommand(cmd); + + Assert.AreEqual(1, cmd.ExecuteCount); + } + + [Test] + public void ExecuteCommand_MakesCanUndoTrue() + { + _history.ExecuteCommand(new FakeCommand()); + + Assert.IsTrue(_history.CanUndo); + } + + [Test] + public void ExecuteCommand_DoesNotMakeCanRedoTrue() + { + _history.ExecuteCommand(new FakeCommand()); + + Assert.IsFalse(_history.CanRedo); + } + + [Test] + public void ExecuteCommand_NullCommand_ThrowsArgumentNullException() + { + Assert.Throws(() => _history.ExecuteCommand(null)); + } + + [Test] + public void ExecuteCommand_ClearsRedoStack() + { + // Build a redo entry + _history.ExecuteCommand(new FakeCommand("A")); + _history.Undo(); // CanRedo is now true + + Assert.IsTrue(_history.CanRedo); + + // Execute a new command — must clear the redo stack + _history.ExecuteCommand(new FakeCommand("B")); + + Assert.IsFalse(_history.CanRedo); + } + + [Test] + public void ExecuteCommand_MultipleCommands_AllExecutedInOrder() + { + var order = new List(); + var cmd1 = new FakeCommand("c1"); + var cmd2 = new FakeCommand("c2"); + var cmd3 = new FakeCommand("c3"); + + _history.ExecuteCommand(cmd1); + _history.ExecuteCommand(cmd2); + _history.ExecuteCommand(cmd3); + + Assert.AreEqual(1, cmd1.ExecuteCount); + Assert.AreEqual(1, cmd2.ExecuteCount); + Assert.AreEqual(1, cmd3.ExecuteCount); + } + + [Test] + public void ExecuteCommand_FiresOnCommandExecutedEvent() + { + ICommand capturedCommand = null; + _history.OnCommandExecuted += cmd => capturedCommand = cmd; + + var fake = new FakeCommand(); + _history.ExecuteCommand(fake); + + Assert.IsNotNull(capturedCommand); + Assert.AreSame(fake, capturedCommand); + } + + [Test] + public void ExecuteCommand_FiresOnHistoryChangedEvent() + { + int changeCount = 0; + _history.OnHistoryChanged += () => changeCount++; + + _history.ExecuteCommand(new FakeCommand()); + + Assert.AreEqual(1, changeCount); + } + + #endregion + + #region Undo Tests + + [Test] + public void Undo_AfterExecute_CallsCommandUndo() + { + var cmd = new FakeCommand(); + _history.ExecuteCommand(cmd); + + _history.Undo(); + + Assert.AreEqual(1, cmd.UndoCount); + } + + [Test] + public void Undo_AfterExecute_MakesCanUndoFalse_WhenNothingLeft() + { + _history.ExecuteCommand(new FakeCommand()); + + _history.Undo(); + + Assert.IsFalse(_history.CanUndo); + } + + [Test] + public void Undo_AfterExecute_MakesCanRedoTrue() + { + _history.ExecuteCommand(new FakeCommand()); + + _history.Undo(); + + Assert.IsTrue(_history.CanRedo); + } + + [Test] + public void Undo_WhenCanUndoFalse_DoesNothing() + { + // Should not throw + Assert.DoesNotThrow(() => _history.Undo()); + } + + [Test] + public void Undo_UndoesInReverseOrder() + { + var order = new List(); + var cmd1 = new FakeCommand("A"); + var cmd2 = new FakeCommand("B"); + _history.ExecuteCommand(cmd1); + _history.ExecuteCommand(cmd2); + + _history.Undo(); + _history.Undo(); + + // cmd2 undone first, then cmd1 + Assert.AreEqual(1, cmd2.UndoCount); + Assert.AreEqual(1, cmd1.UndoCount); + } + + [Test] + public void Undo_FiresOnUndoEvent() + { + ICommand capturedCommand = null; + var fake = new FakeCommand(); + _history.ExecuteCommand(fake); + _history.OnUndo += cmd => capturedCommand = cmd; + + _history.Undo(); + + Assert.IsNotNull(capturedCommand); + Assert.AreSame(fake, capturedCommand); + } + + [Test] + public void Undo_FiresOnHistoryChangedEvent() + { + _history.ExecuteCommand(new FakeCommand()); + + int changeCount = 0; + _history.OnHistoryChanged += () => changeCount++; + + _history.Undo(); + + Assert.AreEqual(1, changeCount); + } + + [Test] + public void Undo_WhenEmpty_DoesNotFireOnUndoEvent() + { + bool fired = false; + _history.OnUndo += _ => fired = true; + + _history.Undo(); + + Assert.IsFalse(fired); + } + + #endregion + + #region Redo Tests + + [Test] + public void Redo_AfterUndo_ReExecutesCommand() + { + var cmd = new FakeCommand(); + _history.ExecuteCommand(cmd); + _history.Undo(); + + _history.Redo(); + + Assert.AreEqual(2, cmd.ExecuteCount, "Command Execute should be called a second time on Redo"); + } + + [Test] + public void Redo_AfterUndo_MakesCanRedoFalse_WhenNothingLeft() + { + _history.ExecuteCommand(new FakeCommand()); + _history.Undo(); + + _history.Redo(); + + Assert.IsFalse(_history.CanRedo); + } + + [Test] + public void Redo_AfterUndo_MakesCanUndoTrue() + { + _history.ExecuteCommand(new FakeCommand()); + _history.Undo(); + + _history.Redo(); + + Assert.IsTrue(_history.CanUndo); + } + + [Test] + public void Redo_WhenCanRedoFalse_DoesNothing() + { + Assert.DoesNotThrow(() => _history.Redo()); + } + + [Test] + public void Redo_WhenEmpty_DoesNotFireOnRedoEvent() + { + bool fired = false; + _history.OnRedo += _ => fired = true; + + _history.Redo(); + + Assert.IsFalse(fired); + } + + [Test] + public void Redo_FiresOnRedoEvent() + { + ICommand capturedCommand = null; + var fake = new FakeCommand(); + _history.ExecuteCommand(fake); + _history.Undo(); + _history.OnRedo += cmd => capturedCommand = cmd; + + _history.Redo(); + + Assert.IsNotNull(capturedCommand); + Assert.AreSame(fake, capturedCommand); + } + + [Test] + public void Redo_FiresOnHistoryChangedEvent() + { + _history.ExecuteCommand(new FakeCommand()); + _history.Undo(); + + int changeCount = 0; + _history.OnHistoryChanged += () => changeCount++; + + _history.Redo(); + + Assert.AreEqual(1, changeCount); + } + + #endregion + + #region Execute-Undo-Redo Cycle Tests + + [Test] + public void FullCycle_Execute_Undo_Redo_ProducesCorrectState() + { + var cmd = new FakeCommand(); + _history.ExecuteCommand(cmd); + + Assert.IsTrue(_history.CanUndo, "After execute: CanUndo"); + Assert.IsFalse(_history.CanRedo, "After execute: CanRedo false"); + + _history.Undo(); + + Assert.IsFalse(_history.CanUndo, "After undo: CanUndo false"); + Assert.IsTrue(_history.CanRedo, "After undo: CanRedo true"); + + _history.Redo(); + + Assert.IsTrue(_history.CanUndo, "After redo: CanUndo true"); + Assert.IsFalse(_history.CanRedo, "After redo: CanRedo false"); + + Assert.AreEqual(2, cmd.ExecuteCount, "Execute count: initial + redo"); + Assert.AreEqual(1, cmd.UndoCount); + } + + [Test] + public void MultipleUndo_ThenMultipleRedo_CorrectlyRestoresStack() + { + var cmd1 = new FakeCommand("A"); + var cmd2 = new FakeCommand("B"); + _history.ExecuteCommand(cmd1); + _history.ExecuteCommand(cmd2); + + _history.Undo(); + _history.Undo(); + + Assert.IsFalse(_history.CanUndo); + Assert.IsTrue(_history.CanRedo); + + _history.Redo(); + _history.Redo(); + + Assert.IsTrue(_history.CanUndo); + Assert.IsFalse(_history.CanRedo); + Assert.AreEqual(2, cmd1.ExecuteCount); + Assert.AreEqual(2, cmd2.ExecuteCount); + } + + #endregion + + #region Capacity Limit Tests + + [Test] + public void CapacityLimit_OldestCommandEvicted_WhenExceeded() + { + var history = new CommandHistory(maxCapacity: 3); + + var oldest = new FakeCommand("oldest"); + history.ExecuteCommand(oldest); + history.ExecuteCommand(new FakeCommand("B")); + history.ExecuteCommand(new FakeCommand("C")); + history.ExecuteCommand(new FakeCommand("newest")); // exceeds capacity by 1 + + // Can still undo 3 times (capacity 3) + history.Undo(); + history.Undo(); + history.Undo(); + + // Should NOT be able to undo the evicted 'oldest' command + Assert.IsFalse(history.CanUndo, "Oldest command should have been evicted"); + // oldest should never have been undone + Assert.AreEqual(0, oldest.UndoCount); + } + + [Test] + public void CapacityLimit_ExactlyAtCapacity_NoEviction() + { + var history = new CommandHistory(maxCapacity: 3); + + var first = new FakeCommand("first"); + history.ExecuteCommand(first); + history.ExecuteCommand(new FakeCommand("B")); + history.ExecuteCommand(new FakeCommand("C")); // exactly at capacity + + // All 3 should be undoable + history.Undo(); + history.Undo(); + history.Undo(); + + Assert.IsFalse(history.CanUndo); + Assert.AreEqual(1, first.UndoCount, "First command should be undone (not evicted)"); + } + + [Test] + public void CapacityLimit_CapacityOne_OnlyOneCommandUndoable() + { + var history = new CommandHistory(maxCapacity: 1); + + var first = new FakeCommand("first"); + var second = new FakeCommand("second"); + history.ExecuteCommand(first); + history.ExecuteCommand(second); // evicts first + + history.Undo(); + + Assert.IsFalse(history.CanUndo); + Assert.AreEqual(0, first.UndoCount, "First command should have been evicted"); + Assert.AreEqual(1, second.UndoCount); + } + + #endregion + + #region Clear Tests + + [Test] + public void Clear_MakesCanUndoFalse() + { + _history.ExecuteCommand(new FakeCommand()); + + _history.Clear(); + + Assert.IsFalse(_history.CanUndo); + } + + [Test] + public void Clear_MakesCanRedoFalse() + { + _history.ExecuteCommand(new FakeCommand()); + _history.Undo(); + + _history.Clear(); + + Assert.IsFalse(_history.CanRedo); + } + + [Test] + public void Clear_FiresOnHistoryChangedEvent() + { + int changeCount = 0; + _history.OnHistoryChanged += () => changeCount++; + + _history.Clear(); + + Assert.AreEqual(1, changeCount); + } + + [Test] + public void Clear_OnEmptyHistory_DoesNotThrow() + { + Assert.DoesNotThrow(() => _history.Clear()); + } + + [Test] + public void Clear_AfterClear_ExecuteCommandWorksNormally() + { + _history.ExecuteCommand(new FakeCommand()); + _history.Clear(); + + var cmd = new FakeCommand(); + _history.ExecuteCommand(cmd); + + Assert.IsTrue(_history.CanUndo); + Assert.AreEqual(1, cmd.ExecuteCount); + } + + #endregion + } +} diff --git a/Assets/10_Scripts/30_Tests/30_Commands/CommandHistoryTests.cs.meta b/Assets/10_Scripts/30_Tests/30_Commands/CommandHistoryTests.cs.meta new file mode 100644 index 0000000..f517dd5 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/30_Commands/CommandHistoryTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a10a6748d86c0d4ba26de4ad1a04eef +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/30_Commands/DeleteTraceNetCommandTests.cs b/Assets/10_Scripts/30_Tests/30_Commands/DeleteTraceNetCommandTests.cs new file mode 100644 index 0000000..9f02e28 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/30_Commands/DeleteTraceNetCommandTests.cs @@ -0,0 +1,397 @@ +using NUnit.Framework; +using System.Collections.Generic; +using CircuitCraft.Core; +using CircuitCraft.Commands; + +namespace CircuitCraft.Tests.Commands +{ + /// + /// Characterization tests for DeleteTraceNetCommand — captures ALL current behavior as a + /// safety net before any refactoring. Tests must pass against the CURRENT implementation as-is. + /// + /// DeleteTraceNetCommand captures the net's name, all traces, and all pin connections, then + /// removes all traces (causing the net to be auto-deleted). Undo recreates the net and + /// restores traces and pin connections, getting a new net ID. + /// + [TestFixture] + public class DeleteTraceNetCommandTests + { + private BoardState _board; + + [SetUp] + public void SetUp() + { + _board = new BoardState(20, 20); + } + + #region Helper Methods + + private static List CreatePins(int count) + { + var pins = new List(); + for (int i = 0; i < count; i++) + pins.Add(new PinInstance(i, $"pin{i}", new GridPosition(i, 0))); + return pins; + } + + private PlacedComponent PlaceAt(int x, int y, string defId = "resistor", int pinCount = 2) + { + return _board.PlaceComponent(defId, new GridPosition(x, y), 0, CreatePins(pinCount)); + } + + private void ConnectPin(PlacedComponent comp, int pinIndex, Net net) + { + _board.ConnectPinToNet(net.NetId, + new PinReference(comp.InstanceId, pinIndex, comp.GetPinWorldPosition(pinIndex))); + } + + #endregion + + #region Description Tests + + [Test] + public void Description_ContainsNetId() + { + var net = _board.CreateNet("NET_A"); + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + + var cmd = new DeleteTraceNetCommand(_board, net.NetId); + + Assert.IsTrue(cmd.Description.Contains(net.NetId.ToString()), + $"Description should contain net ID. Actual: '{cmd.Description}'"); + } + + [Test] + public void Description_IsNotNullOrEmpty() + { + var net = _board.CreateNet("NET_A"); + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + + var cmd = new DeleteTraceNetCommand(_board, net.NetId); + + Assert.IsNotNull(cmd.Description); + Assert.IsNotEmpty(cmd.Description); + } + + #endregion + + #region Execute Tests + + [Test] + public void Execute_RemovesAllTraces() + { + var net = _board.CreateNet("NET_A"); + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + _board.AddTrace(net.NetId, new GridPosition(5, 0), new GridPosition(5, 5)); + + var cmd = new DeleteTraceNetCommand(_board, net.NetId); + cmd.Execute(); + + Assert.AreEqual(0, _board.Traces.Count); + } + + [Test] + public void Execute_AutoDeletesNet_WhenLastTraceRemoved() + { + var net = _board.CreateNet("NET_A"); + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + int netId = net.NetId; + + var cmd = new DeleteTraceNetCommand(_board, netId); + cmd.Execute(); + + Assert.IsNull(_board.GetNet(netId), "Net should be auto-deleted when last trace is removed"); + } + + [Test] + public void Execute_NonExistentNetId_DoesNotThrow() + { + var cmd = new DeleteTraceNetCommand(_board, 9999); + + Assert.DoesNotThrow(() => cmd.Execute()); + } + + [Test] + public void Execute_NonExistentNetId_BoardUnchanged() + { + var net = _board.CreateNet("OTHER"); + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + + var cmd = new DeleteTraceNetCommand(_board, 9999); + cmd.Execute(); + + Assert.AreEqual(1, _board.Traces.Count, "Only traces for the target net should be removed"); + } + + [Test] + public void Execute_ClearsPinConnections_WhenNetAutoDeleted() + { + var comp = PlaceAt(0, 0); + var net = _board.CreateNet("NET_A"); + ConnectPin(comp, 0, net); + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + + var cmd = new DeleteTraceNetCommand(_board, net.NetId); + cmd.Execute(); + + // Pin connection is cleared because net was auto-deleted by RemoveTrace + Assert.IsNull(comp.Pins[0].ConnectedNetId, + "CHARACTERIZATION: Pin connection cleared when net auto-deleted via RemoveTrace"); + } + + [Test] + public void Execute_OnlyRemovesTargetNetTraces_LeavesOtherNetIntact() + { + var netA = _board.CreateNet("NET_A"); + var netB = _board.CreateNet("NET_B"); + _board.AddTrace(netA.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + _board.AddTrace(netB.NetId, new GridPosition(0, 5), new GridPosition(5, 5)); + + var cmd = new DeleteTraceNetCommand(_board, netA.NetId); + cmd.Execute(); + + Assert.AreEqual(1, _board.Traces.Count, "NET_B trace should remain"); + Assert.IsNotNull(_board.GetNet(netB.NetId), "NET_B should still exist"); + } + + [Test] + public void Execute_SingleTrace_RemovesItAndDeletesNet() + { + var net = _board.CreateNet("MY_NET"); + _board.AddTrace(net.NetId, new GridPosition(1, 1), new GridPosition(4, 1)); + int savedNetId = net.NetId; + + var cmd = new DeleteTraceNetCommand(_board, savedNetId); + cmd.Execute(); + + Assert.AreEqual(0, _board.Traces.Count); + Assert.IsNull(_board.GetNet(savedNetId)); + } + + #endregion + + #region Undo Tests + + [Test] + public void Undo_WithoutExecute_DoesNothing() + { + var net = _board.CreateNet("NET_A"); + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + int traceCountBefore = _board.Traces.Count; + + var cmd = new DeleteTraceNetCommand(_board, net.NetId); + // Undo without Execute — hasCapturedState is false → no-op + Assert.DoesNotThrow(() => cmd.Undo()); + Assert.AreEqual(traceCountBefore, _board.Traces.Count); + } + + [Test] + public void Undo_AfterExecute_RecreatesNet() + { + var net = _board.CreateNet("MY_NET"); + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + + var cmd = new DeleteTraceNetCommand(_board, net.NetId); + cmd.Execute(); + + Assert.AreEqual(0, _board.Nets.Count, "Net should be gone after Execute"); + + cmd.Undo(); + + Assert.AreEqual(1, _board.Nets.Count, "Net should be recreated after Undo"); + } + + [Test] + public void Undo_AfterExecute_RecreatesNetWithSameName() + { + var net = _board.CreateNet("SPECIAL_NET"); + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + + var cmd = new DeleteTraceNetCommand(_board, net.NetId); + cmd.Execute(); + cmd.Undo(); + + var recreated = _board.GetNetByName("SPECIAL_NET"); + Assert.IsNotNull(recreated, "Recreated net should have the same name"); + } + + [Test] + public void Undo_AfterExecute_RestoredNetHasNewId() + { + var net = _board.CreateNet("NET_A"); + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + int originalNetId = net.NetId; + + var cmd = new DeleteTraceNetCommand(_board, net.NetId); + cmd.Execute(); + cmd.Undo(); + + var recreated = _board.GetNetByName("NET_A"); + Assert.IsNotNull(recreated); + Assert.AreNotEqual(originalNetId, recreated.NetId, + "CHARACTERIZATION: Recreated net gets a new auto-incremented NetId"); + } + + [Test] + public void Undo_AfterExecute_RestoresSingleTrace() + { + var net = _board.CreateNet("NET_A"); + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + + var cmd = new DeleteTraceNetCommand(_board, net.NetId); + cmd.Execute(); + + Assert.AreEqual(0, _board.Traces.Count); + + cmd.Undo(); + + Assert.AreEqual(1, _board.Traces.Count, "Trace should be restored after Undo"); + } + + [Test] + public void Undo_AfterExecute_RestoresMultipleTraces() + { + var net = _board.CreateNet("NET_A"); + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + _board.AddTrace(net.NetId, new GridPosition(5, 0), new GridPosition(5, 5)); + _board.AddTrace(net.NetId, new GridPosition(5, 5), new GridPosition(8, 5)); + + var cmd = new DeleteTraceNetCommand(_board, net.NetId); + cmd.Execute(); + + Assert.AreEqual(0, _board.Traces.Count); + + cmd.Undo(); + + Assert.AreEqual(3, _board.Traces.Count, "All 3 traces should be restored"); + } + + [Test] + public void Undo_AfterExecute_RestoredTracesOnRestoredNet() + { + var net = _board.CreateNet("NET_A"); + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + + var cmd = new DeleteTraceNetCommand(_board, net.NetId); + cmd.Execute(); + cmd.Undo(); + + var recreated = _board.GetNetByName("NET_A"); + Assert.IsNotNull(recreated); + var traces = _board.GetTraces(recreated.NetId); + Assert.AreEqual(1, traces.Count, "Restored trace should belong to the recreated net"); + } + + [Test] + public void Undo_AfterExecute_RestoresPinConnections() + { + var comp = PlaceAt(0, 0); + var net = _board.CreateNet("VIN"); + ConnectPin(comp, 0, net); + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + + var cmd = new DeleteTraceNetCommand(_board, net.NetId); + cmd.Execute(); + + // After execute: pin connection is cleared + Assert.IsNull(comp.Pins[0].ConnectedNetId); + + cmd.Undo(); + + // After undo: pin should be reconnected to the recreated net + Assert.IsTrue(comp.Pins[0].ConnectedNetId.HasValue, + "Pin connection should be restored after Undo"); + } + + [Test] + public void Undo_AfterExecute_MultiplePinsRestored() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 0); + var net = _board.CreateNet("NET_A"); + ConnectPin(comp1, 0, net); + ConnectPin(comp2, 0, net); + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + + var cmd = new DeleteTraceNetCommand(_board, net.NetId); + cmd.Execute(); + cmd.Undo(); + + var recreated = _board.GetNetByName("NET_A"); + Assert.IsNotNull(recreated); + Assert.AreEqual(2, recreated.ConnectedPins.Count, + "Both pin connections should be restored after Undo"); + } + + [Test] + public void Undo_NonExistentNet_DoesNothing_SinceExecuteWasNoOp() + { + var cmd = new DeleteTraceNetCommand(_board, 9999); + cmd.Execute(); // sets hasCapturedState = false + + Assert.DoesNotThrow(() => cmd.Undo()); + Assert.AreEqual(0, _board.Nets.Count); + } + + #endregion + + #region Execute-Undo Cycle via CommandHistory + + [Test] + public void ExecuteViaHistory_ThenUndo_ThenRedo_CycleCorrectly() + { + var net = _board.CreateNet("NET_A"); + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + var history = new CommandHistory(); + var cmd = new DeleteTraceNetCommand(_board, net.NetId); + + history.ExecuteCommand(cmd); + Assert.AreEqual(0, _board.Traces.Count, "After execute: traces gone"); + Assert.IsNull(_board.GetNet(net.NetId), "After execute: net gone"); + + history.Undo(); + Assert.AreEqual(1, _board.Traces.Count, "After undo: trace restored"); + Assert.IsNotNull(_board.GetNetByName("NET_A"), "After undo: net recreated"); + + history.Redo(); + Assert.AreEqual(0, _board.Traces.Count, "After redo: traces gone again"); + } + + [Test] + public void ExecuteViaHistory_WithPins_UndoRestoresPinConnections() + { + var comp = PlaceAt(0, 0); + var net = _board.CreateNet("VIN"); + ConnectPin(comp, 0, net); + _board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + + var history = new CommandHistory(); + var cmd = new DeleteTraceNetCommand(_board, net.NetId); + + history.ExecuteCommand(cmd); + Assert.IsNull(comp.Pins[0].ConnectedNetId, "After execute: pin disconnected"); + + history.Undo(); + Assert.IsTrue(comp.Pins[0].ConnectedNetId.HasValue, + "After undo: pin should be reconnected"); + } + + [Test] + public void ExecuteViaHistory_OnlyTargetNetDeleted_OtherNetsUnaffected() + { + var netA = _board.CreateNet("NET_A"); + var netB = _board.CreateNet("NET_B"); + _board.AddTrace(netA.NetId, new GridPosition(0, 0), new GridPosition(5, 0)); + _board.AddTrace(netB.NetId, new GridPosition(0, 5), new GridPosition(5, 5)); + + var history = new CommandHistory(); + history.ExecuteCommand(new DeleteTraceNetCommand(_board, netA.NetId)); + + Assert.IsNull(_board.GetNet(netA.NetId), "NET_A should be deleted"); + Assert.IsNotNull(_board.GetNet(netB.NetId), "NET_B should be unaffected"); + Assert.AreEqual(1, _board.Traces.Count, "NET_B trace should remain"); + } + + #endregion + } +} diff --git a/Assets/10_Scripts/30_Tests/30_Commands/DeleteTraceNetCommandTests.cs.meta b/Assets/10_Scripts/30_Tests/30_Commands/DeleteTraceNetCommandTests.cs.meta new file mode 100644 index 0000000..7646b6e --- /dev/null +++ b/Assets/10_Scripts/30_Tests/30_Commands/DeleteTraceNetCommandTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 027af6ccaab4b7e4d913ec7bae196f60 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/30_Commands/PlaceComponentCommandTests.cs b/Assets/10_Scripts/30_Tests/30_Commands/PlaceComponentCommandTests.cs new file mode 100644 index 0000000..4517640 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/30_Commands/PlaceComponentCommandTests.cs @@ -0,0 +1,346 @@ +using NUnit.Framework; +using System.Collections.Generic; +using CircuitCraft.Core; +using CircuitCraft.Commands; + +namespace CircuitCraft.Tests.Commands +{ + /// + /// Characterization tests for PlaceComponentCommand — captures ALL current behavior as a + /// safety net before any refactoring. + /// + [TestFixture] + public class PlaceComponentCommandTests + { + private BoardState _board; + + [SetUp] + public void SetUp() + { + _board = new BoardState(10, 10); + } + + #region Helper Methods + + private static List CreatePins(int count) + { + var pins = new List(); + for (int i = 0; i < count; i++) + pins.Add(new PinInstance(i, $"pin{i}", new GridPosition(i, 0))); + return pins; + } + + private PlaceComponentCommand MakeCommand( + string defId = "resistor", + int x = 3, + int y = 3, + int rotation = 0, + int pinCount = 2, + float? customValue = null) + { + return new PlaceComponentCommand( + _board, defId, new GridPosition(x, y), rotation, CreatePins(pinCount), customValue); + } + + #endregion + + #region Description Tests + + [Test] + public void Description_ContainsComponentDefId() + { + var cmd = MakeCommand(defId: "led_red"); + Assert.IsTrue(cmd.Description.Contains("led_red"), + $"Expected description to contain defId. Actual: '{cmd.Description}'"); + } + + [Test] + public void Description_ContainsPosition() + { + var cmd = MakeCommand(x: 5, y: 7); + Assert.IsTrue(cmd.Description.Contains("5") || cmd.Description.Contains("7"), + $"Expected description to contain position. Actual: '{cmd.Description}'"); + } + + [Test] + public void Description_IsNotNullOrEmpty() + { + var cmd = MakeCommand(); + Assert.IsNotNull(cmd.Description); + Assert.IsNotEmpty(cmd.Description); + } + + #endregion + + #region Execute Tests + + [Test] + public void Execute_PlacesComponentOnBoard() + { + var cmd = MakeCommand(x: 3, y: 3); + Assert.AreEqual(0, _board.Components.Count); + + cmd.Execute(); + + Assert.AreEqual(1, _board.Components.Count); + } + + [Test] + public void Execute_ComponentHasCorrectDefId() + { + var cmd = MakeCommand(defId: "capacitor_100nf", x: 0, y: 0); + cmd.Execute(); + + var comp = _board.Components[0]; + Assert.AreEqual("capacitor_100nf", comp.ComponentDefinitionId); + } + + [Test] + public void Execute_ComponentHasCorrectPosition() + { + var cmd = MakeCommand(x: 4, y: 6); + cmd.Execute(); + + var comp = _board.Components[0]; + Assert.AreEqual(new GridPosition(4, 6), comp.Position); + } + + [Test] + public void Execute_ComponentHasCorrectRotation() + { + var cmd = MakeCommand(rotation: 90); + cmd.Execute(); + + var comp = _board.Components[0]; + Assert.AreEqual(90, comp.Rotation); + } + + [Test] + public void Execute_ComponentHasCorrectPinCount() + { + var cmd = MakeCommand(pinCount: 3); + cmd.Execute(); + + var comp = _board.Components[0]; + Assert.AreEqual(3, comp.Pins.Count); + } + + [Test] + public void Execute_ComponentHasCorrectCustomValue() + { + var cmd = MakeCommand(customValue: 4700f); + cmd.Execute(); + + var comp = _board.Components[0]; + Assert.IsTrue(comp.CustomValue.HasValue); + Assert.AreEqual(4700f, comp.CustomValue.Value, 0.001f); + } + + [Test] + public void Execute_ComponentHasNullCustomValue_WhenNotProvided() + { + var cmd = MakeCommand(customValue: null); + cmd.Execute(); + + var comp = _board.Components[0]; + Assert.IsFalse(comp.CustomValue.HasValue); + } + + [Test] + public void Execute_ComponentHasZeroCustomValue_WhenExplicitlySet() + { + var cmd = MakeCommand(customValue: 0f); + cmd.Execute(); + + var comp = _board.Components[0]; + Assert.IsTrue(comp.CustomValue.HasValue); + Assert.AreEqual(0f, comp.CustomValue.Value, 0.001f); + } + + [Test] + public void Execute_ComponentIsNotFixed() + { + var cmd = MakeCommand(); + cmd.Execute(); + + var comp = _board.Components[0]; + Assert.IsFalse(comp.IsFixed); + } + + [Test] + public void Execute_WithNullPins_StillPlacesComponent() + { + var cmd = new PlaceComponentCommand( + _board, "resistor", new GridPosition(0, 0), 0, null); + + cmd.Execute(); + + Assert.AreEqual(1, _board.Components.Count); + Assert.AreEqual(0, _board.Components[0].Pins.Count); + } + + [Test] + public void Execute_Twice_PlacesSecondComponentWithDifferentId() + { + var cmd1 = MakeCommand(x: 0, y: 0); + var cmd2 = MakeCommand(x: 5, y: 5); + + cmd1.Execute(); + cmd2.Execute(); + + Assert.AreEqual(2, _board.Components.Count); + Assert.AreNotEqual(_board.Components[0].InstanceId, _board.Components[1].InstanceId); + } + + #endregion + + #region Undo Tests + + [Test] + public void Undo_AfterExecute_RemovesComponentFromBoard() + { + var cmd = MakeCommand(); + cmd.Execute(); + + Assert.AreEqual(1, _board.Components.Count); + + cmd.Undo(); + + Assert.AreEqual(0, _board.Components.Count); + } + + [Test] + public void Undo_AfterExecute_PositionBecomesVacant() + { + var cmd = MakeCommand(x: 3, y: 3); + cmd.Execute(); + cmd.Undo(); + + Assert.IsFalse(_board.IsPositionOccupied(new GridPosition(3, 3))); + } + + [Test] + public void Undo_AfterExecute_ComponentNotFoundById() + { + var cmd = MakeCommand(); + cmd.Execute(); + int placedId = _board.Components[0].InstanceId; + + cmd.Undo(); + + Assert.IsNull(_board.GetComponent(placedId)); + } + + [Test] + public void Undo_WithoutExecute_DoesNotThrow() + { + var cmd = MakeCommand(); + + // Undo without Execute — should not throw (no placed instance id yet) + Assert.DoesNotThrow(() => cmd.Undo()); + } + + [Test] + public void Undo_AfterExecute_BoardIsEmpty() + { + var cmd = MakeCommand(); + cmd.Execute(); + cmd.Undo(); + + Assert.AreEqual(0, _board.Components.Count); + } + + #endregion + + #region Execute-Undo Cycle Tests + + [Test] + public void ExecuteUndo_ThenExecuteAgain_PlacesComponentAtSamePosition() + { + var cmd = MakeCommand(x: 2, y: 2); + + cmd.Execute(); + int firstId = _board.Components[0].InstanceId; + cmd.Undo(); + cmd.Execute(); + int secondId = _board.Components[0].InstanceId; + + // Both placed at same position but different InstanceIds (auto-increment) + Assert.AreEqual(1, _board.Components.Count); + Assert.AreEqual(new GridPosition(2, 2), _board.Components[0].Position); + Assert.AreNotEqual(firstId, secondId, "Re-execute should produce a new InstanceId"); + } + + [Test] + public void Execute_ViaCommandHistory_ThenUndo_RemovesComponent() + { + var history = new CommandHistory(); + var cmd = MakeCommand(); + + history.ExecuteCommand(cmd); + Assert.AreEqual(1, _board.Components.Count); + + history.Undo(); + Assert.AreEqual(0, _board.Components.Count); + } + + [Test] + public void Execute_ViaCommandHistory_ThenUndo_ThenRedo_ReplacesComponent() + { + var history = new CommandHistory(); + var cmd = MakeCommand(); + + history.ExecuteCommand(cmd); + history.Undo(); + history.Redo(); + + Assert.AreEqual(1, _board.Components.Count); + } + + #endregion + + #region Edge Cases + + [Test] + public void Execute_WithRotation270_IsAccepted() + { + var cmd = MakeCommand(rotation: 270); + cmd.Execute(); + + Assert.AreEqual(270, _board.Components[0].Rotation); + } + + [Test] + public void Execute_WithRotation180_IsAccepted() + { + var cmd = MakeCommand(rotation: 180); + cmd.Execute(); + + Assert.AreEqual(180, _board.Components[0].Rotation); + } + + [Test] + public void Execute_WithEmptyPinList_ComponentHasZeroPins() + { + var cmd = new PlaceComponentCommand( + _board, "ground", new GridPosition(0, 0), 0, new List()); + + cmd.Execute(); + + Assert.AreEqual(0, _board.Components[0].Pins.Count); + } + + [Test] + public void Execute_WithSinglePin_ComponentHasOnePin() + { + var pins = new List { new PinInstance(0, "anode", new GridPosition(0, 0)) }; + var cmd = new PlaceComponentCommand(_board, "probe", new GridPosition(0, 0), 0, pins); + + cmd.Execute(); + + Assert.AreEqual(1, _board.Components[0].Pins.Count); + } + + #endregion + } +} diff --git a/Assets/10_Scripts/30_Tests/30_Commands/PlaceComponentCommandTests.cs.meta b/Assets/10_Scripts/30_Tests/30_Commands/PlaceComponentCommandTests.cs.meta new file mode 100644 index 0000000..6f3de6c --- /dev/null +++ b/Assets/10_Scripts/30_Tests/30_Commands/PlaceComponentCommandTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e160a916861bbfa4294d330d431b332c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/30_Commands/RemoveComponentCommandTests.cs b/Assets/10_Scripts/30_Tests/30_Commands/RemoveComponentCommandTests.cs new file mode 100644 index 0000000..23f8878 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/30_Commands/RemoveComponentCommandTests.cs @@ -0,0 +1,359 @@ +using NUnit.Framework; +using System.Collections.Generic; +using CircuitCraft.Core; +using CircuitCraft.Commands; + +namespace CircuitCraft.Tests.Commands +{ + /// + /// Characterization tests for RemoveComponentCommand — captures ALL current behavior as a + /// safety net before any refactoring. Tests must pass against the CURRENT implementation as-is. + /// + [TestFixture] + public class RemoveComponentCommandTests + { + private BoardState _board; + + [SetUp] + public void SetUp() + { + _board = new BoardState(10, 10); + } + + #region Helper Methods + + private static List CreatePins(int count) + { + var pins = new List(); + for (int i = 0; i < count; i++) + pins.Add(new PinInstance(i, $"pin{i}", new GridPosition(i, 0))); + return pins; + } + + private PlacedComponent PlaceAt(int x, int y, string defId = "resistor", int pinCount = 2) + { + return _board.PlaceComponent(defId, new GridPosition(x, y), 0, CreatePins(pinCount)); + } + + private PlacedComponent PlaceFixedAt(int x, int y, string defId = "vsource") + { + return _board.PlaceComponent(defId, new GridPosition(x, y), 0, CreatePins(2), null, isFixed: true); + } + + private void ConnectPin(PlacedComponent comp, int pinIndex, Net net) + { + var pinRef = new PinReference(comp.InstanceId, pinIndex, comp.GetPinWorldPosition(pinIndex)); + _board.ConnectPinToNet(net.NetId, pinRef); + } + + #endregion + + #region Description Tests + + [Test] + public void Description_ContainsInstanceId() + { + var comp = PlaceAt(0, 0); + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + + Assert.IsTrue(cmd.Description.Contains(comp.InstanceId.ToString()), + $"Description should contain instance id {comp.InstanceId}. Actual: '{cmd.Description}'"); + } + + [Test] + public void Description_IsNotNullOrEmpty() + { + var comp = PlaceAt(0, 0); + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + + Assert.IsNotNull(cmd.Description); + Assert.IsNotEmpty(cmd.Description); + } + + #endregion + + #region Execute Tests + + [Test] + public void Execute_RemovesComponentFromBoard() + { + var comp = PlaceAt(3, 3); + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + + cmd.Execute(); + + Assert.AreEqual(0, _board.Components.Count); + } + + [Test] + public void Execute_PositionBecomesVacant() + { + var comp = PlaceAt(5, 5); + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + + cmd.Execute(); + + Assert.IsFalse(_board.IsPositionOccupied(new GridPosition(5, 5))); + } + + [Test] + public void Execute_NonExistentInstanceId_DoesNotThrow() + { + var cmd = new RemoveComponentCommand(_board, 9999); + + Assert.DoesNotThrow(() => cmd.Execute()); + } + + [Test] + public void Execute_NonExistentInstanceId_BoardUnchanged() + { + PlaceAt(0, 0); + var cmd = new RemoveComponentCommand(_board, 9999); + + cmd.Execute(); + + Assert.AreEqual(1, _board.Components.Count); + } + + [Test] + public void Execute_FixedComponent_IsNotRemoved() + { + var comp = PlaceFixedAt(0, 0); + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + + cmd.Execute(); + + Assert.AreEqual(1, _board.Components.Count); + } + + [Test] + public void Execute_FixedComponent_DoesNotThrow() + { + var comp = PlaceFixedAt(0, 0); + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + + Assert.DoesNotThrow(() => cmd.Execute()); + } + + [Test] + public void Execute_RemovesConnectedTracesAtPinPosition() + { + // Place component at (2,2) with pin[0] at local (0,0) -> world (2,2) + var comp = _board.PlaceComponent("resistor", new GridPosition(2, 2), 0, + new[] { new PinInstance(0, "pin0", new GridPosition(0, 0)) }); + var net = _board.CreateNet("NET1"); + var pinWorldPos = comp.GetPinWorldPosition(0); // (2,2) + _board.AddTrace(net.NetId, pinWorldPos, new GridPosition(pinWorldPos.X + 3, pinWorldPos.Y)); + + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + cmd.Execute(); + + // Trace touching the pin should be removed (auto-cleanup on RemoveComponent) + Assert.AreEqual(0, _board.Traces.Count); + } + + [Test] + public void Execute_RemovesComponentFromPinNetConnection() + { + var comp = PlaceAt(0, 0); + var net = _board.CreateNet("NET1"); + ConnectPin(comp, 0, net); + + Assert.AreEqual(1, net.ConnectedPins.Count); + + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + cmd.Execute(); + + // Net gets auto-deleted when last pin is removed + Assert.IsNull(_board.GetNet(net.NetId)); + } + + #endregion + + #region Undo Tests + + [Test] + public void Undo_WithoutExecute_DoesNothing() + { + var comp = PlaceAt(0, 0); + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + + // Undo without Execute - hasCapturedState is false, should be a no-op + Assert.DoesNotThrow(() => cmd.Undo()); + Assert.AreEqual(1, _board.Components.Count, "Component should still be present"); + } + + [Test] + public void Undo_AfterExecute_RestoresComponentToBoard() + { + var comp = PlaceAt(3, 3); + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + cmd.Execute(); + + Assert.AreEqual(0, _board.Components.Count); + + cmd.Undo(); + + Assert.AreEqual(1, _board.Components.Count); + } + + [Test] + public void Undo_AfterExecute_ComponentHasSameDefId() + { + var comp = PlaceAt(3, 3, defId: "led_green"); + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + cmd.Execute(); + cmd.Undo(); + + Assert.AreEqual("led_green", _board.Components[0].ComponentDefinitionId); + } + + [Test] + public void Undo_AfterExecute_ComponentHasSamePosition() + { + var comp = PlaceAt(4, 7); + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + cmd.Execute(); + cmd.Undo(); + + Assert.AreEqual(new GridPosition(4, 7), _board.Components[0].Position); + } + + [Test] + public void Undo_AfterExecute_ComponentHasSamePinCount() + { + var comp = PlaceAt(0, 0, pinCount: 3); + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + cmd.Execute(); + cmd.Undo(); + + Assert.AreEqual(3, _board.Components[0].Pins.Count); + } + + [Test] + public void Undo_AfterExecute_RestoresCustomValue() + { + var comp = _board.PlaceComponent("resistor", new GridPosition(0, 0), 0, CreatePins(2), 2200f); + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + cmd.Execute(); + cmd.Undo(); + + var restored = _board.Components[0]; + Assert.IsTrue(restored.CustomValue.HasValue); + Assert.AreEqual(2200f, restored.CustomValue.Value, 0.001f); + } + + [Test] + public void Undo_AfterExecute_RestoredComponentHasNewInstanceId() + { + var comp = PlaceAt(0, 0); + int originalId = comp.InstanceId; + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + cmd.Execute(); + cmd.Undo(); + + // Restored component gets a new auto-incremented InstanceId (not the original one) + int restoredId = _board.Components[0].InstanceId; + Assert.AreNotEqual(originalId, restoredId, + "CHARACTERIZATION: Restored component receives a new InstanceId (auto-increment)"); + } + + [Test] + public void Undo_AfterExecute_RestoresNetConnection() + { + var comp = PlaceAt(0, 0); + var net = _board.CreateNet("NET_A"); + ConnectPin(comp, 0, net); + + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + cmd.Execute(); + + // Net is gone after execute (auto-deleted when pin removed) + Assert.IsNull(_board.GetNet(net.NetId)); + + cmd.Undo(); + + // Net should be recreated + var restoredNet = _board.GetNetByName("NET_A"); + Assert.IsNotNull(restoredNet, "Net should be recreated after Undo"); + Assert.AreEqual(1, restoredNet.ConnectedPins.Count, "Restored net should have 1 pin"); + } + + [Test] + public void Undo_AfterExecute_RestoresPinToRestoredNet() + { + var comp = PlaceAt(0, 0); + var net = _board.CreateNet("VIN"); + ConnectPin(comp, 0, net); + + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + cmd.Execute(); + cmd.Undo(); + + var restoredComp = _board.Components[0]; + Assert.IsTrue(restoredComp.Pins[0].ConnectedNetId.HasValue, + "Restored component's pin should be connected to a net"); + } + + [Test] + public void Undo_FixedComponent_DoesNotRestore_SinceExecuteWasNoOp() + { + var comp = PlaceFixedAt(0, 0); + int originalCount = _board.Components.Count; + + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + cmd.Execute(); // no-op for fixed + cmd.Undo(); // hasCapturedState=false → no-op + + Assert.AreEqual(originalCount, _board.Components.Count); + } + + [Test] + public void Undo_AfterExecute_PositionIsOccupied() + { + var comp = PlaceAt(3, 3); + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + cmd.Execute(); + cmd.Undo(); + + Assert.IsTrue(_board.IsPositionOccupied(new GridPosition(3, 3))); + } + + #endregion + + #region Execute-Undo-Redo Cycle via CommandHistory + + [Test] + public void ExecuteViaHistory_ThenUndo_ThenRedo_RemovesComponent() + { + var comp = PlaceAt(0, 0); + var history = new CommandHistory(); + var cmd = new RemoveComponentCommand(_board, comp.InstanceId); + + history.ExecuteCommand(cmd); + Assert.AreEqual(0, _board.Components.Count, "After execute: component removed"); + + history.Undo(); + Assert.AreEqual(1, _board.Components.Count, "After undo: component restored"); + + history.Redo(); + Assert.AreEqual(0, _board.Components.Count, "After redo: component removed again"); + } + + [Test] + public void ExecuteViaHistory_MultipleComponents_OnlyTargetIsRemoved() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 5); + var history = new CommandHistory(); + var cmd = new RemoveComponentCommand(_board, comp1.InstanceId); + + history.ExecuteCommand(cmd); + + Assert.AreEqual(1, _board.Components.Count); + Assert.AreEqual(comp2.InstanceId, _board.Components[0].InstanceId); + } + + #endregion + } +} diff --git a/Assets/10_Scripts/30_Tests/30_Commands/RemoveComponentCommandTests.cs.meta b/Assets/10_Scripts/30_Tests/30_Commands/RemoveComponentCommandTests.cs.meta new file mode 100644 index 0000000..9d05cb3 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/30_Commands/RemoveComponentCommandTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 412fbe74d845fb745974415fad3ec7e1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/30_Commands/RouteTraceCommandTests.cs b/Assets/10_Scripts/30_Tests/30_Commands/RouteTraceCommandTests.cs new file mode 100644 index 0000000..fbcae99 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/30_Commands/RouteTraceCommandTests.cs @@ -0,0 +1,498 @@ +using NUnit.Framework; +using System.Collections.Generic; +using CircuitCraft.Core; +using CircuitCraft.Commands; + +namespace CircuitCraft.Tests.Commands +{ + /// + /// Characterization tests for RouteTraceCommand — captures ALL current behavior as a safety net + /// before any refactoring. Tests must pass against the CURRENT implementation as-is. + /// + /// RouteTraceCommand is the most complex command: it resolves which net to use (creating new + /// nets when needed, merging nets when two already-connected pins are joined), adds trace + /// segments, connects both pins, and on Undo reverses all of that. + /// + [TestFixture] + public class RouteTraceCommandTests + { + private BoardState _board; + + [SetUp] + public void SetUp() + { + _board = new BoardState(20, 20); + } + + #region Helper Methods + + private static List CreatePins(int count) + { + var pins = new List(); + for (int i = 0; i < count; i++) + pins.Add(new PinInstance(i, $"pin{i}", new GridPosition(i, 0))); + return pins; + } + + private PlacedComponent PlaceAt(int x, int y, string defId = "resistor", int pinCount = 2) + { + return _board.PlaceComponent(defId, new GridPosition(x, y), 0, CreatePins(pinCount)); + } + + /// + /// Creates a single-segment list for RouteTraceCommand. + /// + private static List<(GridPosition start, GridPosition end)> Segments( + int x1, int y1, int x2, int y2) + { + return new List<(GridPosition, GridPosition)> + { + (new GridPosition(x1, y1), new GridPosition(x2, y2)) + }; + } + + private static List<(GridPosition start, GridPosition end)> MultiSegments( + int x1, int y1, int x2, int y2, int x3, int y3) + { + return new List<(GridPosition, GridPosition)> + { + (new GridPosition(x1, y1), new GridPosition(x2, y2)), + (new GridPosition(x2, y2), new GridPosition(x3, y3)) + }; + } + + private PinReference PinRef(PlacedComponent comp, int pinIndex) + { + return new PinReference(comp.InstanceId, pinIndex, comp.GetPinWorldPosition(pinIndex)); + } + + #endregion + + #region Description Tests + + [Test] + public void Description_IsNotNullOrEmpty() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 0); + + var cmd = new RouteTraceCommand( + _board, + PinRef(comp1, 0), + PinRef(comp2, 0), + Segments(0, 0, 5, 0)); + + Assert.IsNotNull(cmd.Description); + Assert.IsNotEmpty(cmd.Description); + } + + #endregion + + #region Execute — Neither Pin Previously Connected (new net created) + + [Test] + public void Execute_NeitherPinConnected_CreatesNewNet() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 0); + + var cmd = new RouteTraceCommand( + _board, + PinRef(comp1, 0), + PinRef(comp2, 0), + Segments(0, 0, 5, 0)); + + cmd.Execute(); + + Assert.AreEqual(1, _board.Nets.Count, "A new net should have been created"); + } + + [Test] + public void Execute_NeitherPinConnected_AddsTraceSegment() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 0); + + var cmd = new RouteTraceCommand( + _board, + PinRef(comp1, 0), + PinRef(comp2, 0), + Segments(0, 0, 5, 0)); + + cmd.Execute(); + + Assert.AreEqual(1, _board.Traces.Count); + } + + [Test] + public void Execute_NeitherPinConnected_ConnectsBothPinsToNewNet() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 0); + var cmd = new RouteTraceCommand( + _board, + PinRef(comp1, 0), + PinRef(comp2, 0), + Segments(0, 0, 5, 0)); + + cmd.Execute(); + + Assert.IsTrue(comp1.Pins[0].ConnectedNetId.HasValue, + "comp1 pin0 should be connected to a net"); + Assert.IsTrue(comp2.Pins[0].ConnectedNetId.HasValue, + "comp2 pin0 should be connected to a net"); + Assert.AreEqual(comp1.Pins[0].ConnectedNetId, comp2.Pins[0].ConnectedNetId, + "Both pins should be on the same net"); + } + + [Test] + public void Execute_NeitherPinConnected_GroundPin_CreatesNetNamed0() + { + // A component with defId="ground" triggers the ground net name logic + var groundComp = _board.PlaceComponent("ground", new GridPosition(0, 0), 0, CreatePins(1)); + var otherComp = PlaceAt(5, 0); + + var cmd = new RouteTraceCommand( + _board, + PinRef(groundComp, 0), + PinRef(otherComp, 0), + Segments(0, 0, 5, 0)); + + cmd.Execute(); + + Assert.AreEqual(1, _board.Nets.Count); + Assert.AreEqual("0", _board.Nets[0].NetName, + "Ground pin detection should name the net '0'"); + } + + [Test] + public void Execute_MultipleSegments_AddsAllSegments() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 3); + + var cmd = new RouteTraceCommand( + _board, + PinRef(comp1, 0), + PinRef(comp2, 0), + MultiSegments(0, 0, 5, 0, 5, 3)); + + cmd.Execute(); + + Assert.AreEqual(2, _board.Traces.Count, "Both segments should be added"); + } + + #endregion + + #region Execute — One Pin Already Connected (uses existing net) + + [Test] + public void Execute_StartPinAlreadyConnected_UsesExistingNet() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 0); + + var existingNet = _board.CreateNet("VIN"); + _board.ConnectPinToNet(existingNet.NetId, PinRef(comp1, 0)); + + var cmd = new RouteTraceCommand( + _board, + PinRef(comp1, 0), + PinRef(comp2, 0), + Segments(0, 0, 5, 0)); + + cmd.Execute(); + + // Should use existing net, not create a new one + Assert.AreEqual(1, _board.Nets.Count, "Should reuse the existing net"); + Assert.AreEqual(existingNet.NetId, comp2.Pins[0].ConnectedNetId, + "comp2 should be on the same existing net"); + } + + [Test] + public void Execute_EndPinAlreadyConnected_UsesExistingNet() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 0); + + var existingNet = _board.CreateNet("VIN"); + _board.ConnectPinToNet(existingNet.NetId, PinRef(comp2, 0)); + + var cmd = new RouteTraceCommand( + _board, + PinRef(comp1, 0), + PinRef(comp2, 0), + Segments(0, 0, 5, 0)); + + cmd.Execute(); + + Assert.AreEqual(1, _board.Nets.Count); + Assert.AreEqual(existingNet.NetId, comp1.Pins[0].ConnectedNetId, + "comp1 should join the existing net"); + } + + #endregion + + #region Execute — Both Pins On Same Net (no new net, no merge) + + [Test] + public void Execute_BothPinsOnSameNet_DoesNotCreateNewNet() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 0); + + var existingNet = _board.CreateNet("NET1"); + _board.ConnectPinToNet(existingNet.NetId, PinRef(comp1, 0)); + _board.ConnectPinToNet(existingNet.NetId, PinRef(comp2, 0)); + + var cmd = new RouteTraceCommand( + _board, + PinRef(comp1, 0), + PinRef(comp2, 0), + Segments(0, 0, 5, 0)); + + cmd.Execute(); + + Assert.AreEqual(1, _board.Nets.Count, "Should not create additional nets"); + Assert.AreEqual(1, _board.Traces.Count, "Should add trace to existing net"); + } + + #endregion + + #region Execute — Net Merging (two pins on different nets) + + [Test] + public void Execute_PinsOnDifferentNets_MergesIntoOne() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 0); + + var netA = _board.CreateNet("NET_A"); + var netB = _board.CreateNet("NET_B"); + _board.ConnectPinToNet(netA.NetId, PinRef(comp1, 0)); + _board.ConnectPinToNet(netB.NetId, PinRef(comp2, 0)); + + var cmd = new RouteTraceCommand( + _board, + PinRef(comp1, 0), + PinRef(comp2, 0), + Segments(0, 0, 5, 0)); + + cmd.Execute(); + + // After merge: only 1 net should remain (NET_B was merged into NET_A) + Assert.AreEqual(1, _board.Nets.Count, "Merging should reduce net count to 1"); + } + + [Test] + public void Execute_PinsOnDifferentNets_GroundNetPreservedAsTarget() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 0); + + var groundNet = _board.CreateNet("GND"); + var otherNet = _board.CreateNet("NET_B"); + _board.ConnectPinToNet(groundNet.NetId, PinRef(comp1, 0)); + _board.ConnectPinToNet(otherNet.NetId, PinRef(comp2, 0)); + + var cmd = new RouteTraceCommand( + _board, + PinRef(comp1, 0), + PinRef(comp2, 0), + Segments(0, 0, 5, 0)); + + cmd.Execute(); + + // Ground net should be the merge target (preserved) + Assert.AreEqual(1, _board.Nets.Count); + Assert.IsTrue(_board.Nets[0].IsGround, "Ground net should be the merge target"); + } + + #endregion + + #region Undo Tests — New Net Path + + [Test] + public void Undo_AfterNewNetCreated_RemovesTrace() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 0); + var cmd = new RouteTraceCommand( + _board, PinRef(comp1, 0), PinRef(comp2, 0), Segments(0, 0, 5, 0)); + + cmd.Execute(); + Assert.AreEqual(1, _board.Traces.Count); + + cmd.Undo(); + + Assert.AreEqual(0, _board.Traces.Count); + } + + [Test] + public void Undo_AfterNewNetCreated_RemovesNet() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 0); + var cmd = new RouteTraceCommand( + _board, PinRef(comp1, 0), PinRef(comp2, 0), Segments(0, 0, 5, 0)); + + cmd.Execute(); + cmd.Undo(); + + Assert.AreEqual(0, _board.Nets.Count, + "Net created by Execute should be removed on Undo"); + } + + [Test] + public void Undo_AfterNewNetCreated_DisconnectsBothPins() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 0); + var cmd = new RouteTraceCommand( + _board, PinRef(comp1, 0), PinRef(comp2, 0), Segments(0, 0, 5, 0)); + + cmd.Execute(); + cmd.Undo(); + + Assert.IsNull(comp1.Pins[0].ConnectedNetId, "comp1 pin0 should be disconnected"); + Assert.IsNull(comp2.Pins[0].ConnectedNetId, "comp2 pin0 should be disconnected"); + } + + [Test] + public void Undo_MultipleSegments_RemovesAllSegments() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 3); + var cmd = new RouteTraceCommand( + _board, PinRef(comp1, 0), PinRef(comp2, 0), MultiSegments(0, 0, 5, 0, 5, 3)); + + cmd.Execute(); + Assert.AreEqual(2, _board.Traces.Count); + + cmd.Undo(); + + Assert.AreEqual(0, _board.Traces.Count); + } + + #endregion + + #region Undo Tests — Existing Net Path + + [Test] + public void Undo_WhenEndPinJoinedExistingNet_DisconnectsEndPin() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 0); + var existingNet = _board.CreateNet("VIN"); + _board.ConnectPinToNet(existingNet.NetId, PinRef(comp1, 0)); + + var cmd = new RouteTraceCommand( + _board, PinRef(comp1, 0), PinRef(comp2, 0), Segments(0, 0, 5, 0)); + + cmd.Execute(); + // comp2 is now on existingNet + Assert.IsTrue(comp2.Pins[0].ConnectedNetId.HasValue); + + cmd.Undo(); + + // comp2 was not previously connected, so it should be disconnected again + Assert.IsNull(comp2.Pins[0].ConnectedNetId, + "comp2 pin that was newly added should be disconnected on Undo"); + } + + [Test] + public void Undo_WhenBothPinsOnSameNet_KeepsBothPinsConnected() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 0); + var existingNet = _board.CreateNet("NET1"); + _board.ConnectPinToNet(existingNet.NetId, PinRef(comp1, 0)); + _board.ConnectPinToNet(existingNet.NetId, PinRef(comp2, 0)); + + var cmd = new RouteTraceCommand( + _board, PinRef(comp1, 0), PinRef(comp2, 0), Segments(0, 0, 5, 0)); + + cmd.Execute(); + cmd.Undo(); + + // Removing the trace leaves no traces, but net may be deleted (auto-cleanup) + // Both pins were previously on the same net — characterization of actual behavior + // The net gets auto-deleted when last trace is removed (RemoveTrace behavior) + // This characterizes what ACTUALLY happens + Assert.AreEqual(0, _board.Traces.Count, "Trace should be removed on Undo"); + } + + #endregion + + #region Undo Tests — Merge Reversal + + [Test] + public void Undo_AfterMerge_RestoredToTwoNets() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 0); + var netA = _board.CreateNet("NET_A"); + var netB = _board.CreateNet("NET_B"); + _board.ConnectPinToNet(netA.NetId, PinRef(comp1, 0)); + _board.ConnectPinToNet(netB.NetId, PinRef(comp2, 0)); + + var cmd = new RouteTraceCommand( + _board, PinRef(comp1, 0), PinRef(comp2, 0), Segments(0, 0, 5, 0)); + + cmd.Execute(); + Assert.AreEqual(1, _board.Nets.Count, "After execute: merged to 1 net"); + + cmd.Undo(); + + // After undo: the merge is reversed, but trace removal triggers net auto-cleanup + // The source net gets recreated but may be auto-deleted by RemoveTrace + // Characterize ACTUAL behavior: merged target net survives or is auto-deleted + // depending on whether it has traces/pins after undo + Assert.AreEqual(0, _board.Traces.Count, "All traces should be removed after Undo"); + } + + #endregion + + #region Execute-Undo Cycle via CommandHistory + + [Test] + public void ExecuteViaHistory_ThenUndo_ThenRedo_RestoresTrace() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 0); + var history = new CommandHistory(); + var cmd = new RouteTraceCommand( + _board, PinRef(comp1, 0), PinRef(comp2, 0), Segments(0, 0, 5, 0)); + + history.ExecuteCommand(cmd); + Assert.AreEqual(1, _board.Traces.Count, "After execute: trace present"); + + history.Undo(); + Assert.AreEqual(0, _board.Traces.Count, "After undo: trace removed"); + + history.Redo(); + Assert.AreEqual(1, _board.Traces.Count, "After redo: trace restored"); + } + + [Test] + public void ExecuteViaHistory_ThenUndo_ThenRedo_ReconnectsBothPins() + { + var comp1 = PlaceAt(0, 0); + var comp2 = PlaceAt(5, 0); + var history = new CommandHistory(); + var cmd = new RouteTraceCommand( + _board, PinRef(comp1, 0), PinRef(comp2, 0), Segments(0, 0, 5, 0)); + + history.ExecuteCommand(cmd); + history.Undo(); + history.Redo(); + + Assert.IsTrue(comp1.Pins[0].ConnectedNetId.HasValue, "comp1 should be reconnected"); + Assert.IsTrue(comp2.Pins[0].ConnectedNetId.HasValue, "comp2 should be reconnected"); + Assert.AreEqual(comp1.Pins[0].ConnectedNetId, comp2.Pins[0].ConnectedNetId, + "Both pins should be on the same net after Redo"); + } + + #endregion + } +} diff --git a/Assets/10_Scripts/30_Tests/30_Commands/RouteTraceCommandTests.cs.meta b/Assets/10_Scripts/30_Tests/30_Commands/RouteTraceCommandTests.cs.meta new file mode 100644 index 0000000..680fc62 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/30_Commands/RouteTraceCommandTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 873a99f1067f67c48a22745d988e1804 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/40_Components.meta b/Assets/10_Scripts/30_Tests/40_Components.meta new file mode 100644 index 0000000..406a3b4 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/40_Components.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f5aff6336a253714b9c8a5249eb68fd0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/40_Components/ComponentSymbolGeneratorTests.cs b/Assets/10_Scripts/30_Tests/40_Components/ComponentSymbolGeneratorTests.cs new file mode 100644 index 0000000..69358e5 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/40_Components/ComponentSymbolGeneratorTests.cs @@ -0,0 +1,316 @@ +using System.Collections.Generic; +using NUnit.Framework; +using CircuitCraft.Components; +using CircuitCraft.Data; +using UnityEngine; + +namespace CircuitCraft.Tests.Components +{ + [TestFixture] + public class ComponentSymbolGeneratorTests + { + // Track all created Unity objects for cleanup + private readonly List _createdObjects = new List(); + + [TearDown] + public void TearDown() + { + foreach (var obj in _createdObjects) + { + if (obj != null) + { + Object.DestroyImmediate(obj); + } + } + + _createdObjects.Clear(); + } + + // ------------------------------------------------------------------ + // Helper + // ------------------------------------------------------------------ + + private static bool HasNonTransparentPixels(Texture2D texture) + { + var pixels = texture.GetPixels32(); + foreach (var pixel in pixels) + { + if (pixel.a > 0) + { + return true; + } + } + + return false; + } + + // ------------------------------------------------------------------ + // GetOrCreateFallbackSprite — non-null checks per ComponentKind + // ------------------------------------------------------------------ + + [TestCase(ComponentKind.Resistor)] + [TestCase(ComponentKind.Capacitor)] + [TestCase(ComponentKind.Inductor)] + [TestCase(ComponentKind.Diode)] + [TestCase(ComponentKind.LED)] + [TestCase(ComponentKind.BJT)] + [TestCase(ComponentKind.MOSFET)] + [TestCase(ComponentKind.VoltageSource)] + [TestCase(ComponentKind.CurrentSource)] + [TestCase(ComponentKind.Ground)] + [TestCase(ComponentKind.Probe)] + [TestCase(ComponentKind.ZenerDiode)] + public void GetOrCreateFallbackSprite_AllKinds_ReturnsNonNull(ComponentKind kind) + { + var sprite = ComponentSymbolGenerator.GetOrCreateFallbackSprite(kind); + + Assert.IsNotNull(sprite, $"Expected non-null Sprite for {kind}"); + + // Track the sprite's texture for cleanup (sprite itself is managed by static cache) + if (sprite.texture != null) + { + _createdObjects.Add(sprite.texture); + } + } + + // ------------------------------------------------------------------ + // GetOrCreateFallbackSprite — sprite name format + // ------------------------------------------------------------------ + + [TestCase(ComponentKind.Resistor, "Resistor_FallbackSprite")] + [TestCase(ComponentKind.Capacitor, "Capacitor_FallbackSprite")] + [TestCase(ComponentKind.LED, "LED_FallbackSprite")] + [TestCase(ComponentKind.VoltageSource, "VoltageSource_FallbackSprite")] + [TestCase(ComponentKind.Ground, "Ground_FallbackSprite")] + public void GetOrCreateFallbackSprite_ReturnsCorrectSpriteName(ComponentKind kind, string expectedName) + { + var sprite = ComponentSymbolGenerator.GetOrCreateFallbackSprite(kind); + + Assert.AreEqual(expectedName, sprite.name, $"Sprite name mismatch for {kind}"); + } + + // ------------------------------------------------------------------ + // GetOrCreateFallbackSprite — caching: second call returns SAME instance + // ------------------------------------------------------------------ + + [TestCase(ComponentKind.Resistor)] + [TestCase(ComponentKind.Capacitor)] + [TestCase(ComponentKind.Inductor)] + [TestCase(ComponentKind.VoltageSource)] + [TestCase(ComponentKind.Ground)] + public void GetOrCreateFallbackSprite_CalledTwice_ReturnsSameInstance(ComponentKind kind) + { + var first = ComponentSymbolGenerator.GetOrCreateFallbackSprite(kind); + var second = ComponentSymbolGenerator.GetOrCreateFallbackSprite(kind); + + Assert.AreSame(first, second, $"Expected same cached Sprite instance for {kind}"); + } + + // ------------------------------------------------------------------ + // GetOrCreateFallbackSprite — texture has actual drawn pixels (symbol drawn) + // ------------------------------------------------------------------ + + [TestCase(ComponentKind.Resistor)] + [TestCase(ComponentKind.Capacitor)] + [TestCase(ComponentKind.Inductor)] + [TestCase(ComponentKind.Diode)] + [TestCase(ComponentKind.LED)] + [TestCase(ComponentKind.BJT)] + [TestCase(ComponentKind.MOSFET)] + [TestCase(ComponentKind.VoltageSource)] + [TestCase(ComponentKind.CurrentSource)] + [TestCase(ComponentKind.Ground)] + [TestCase(ComponentKind.Probe)] + [TestCase(ComponentKind.ZenerDiode)] + public void GetOrCreateFallbackSprite_AllKinds_TextureHasNonTransparentPixels(ComponentKind kind) + { + var sprite = ComponentSymbolGenerator.GetOrCreateFallbackSprite(kind); + + Assert.IsNotNull(sprite.texture, $"Sprite.texture is null for {kind}"); + Assert.IsTrue( + HasNonTransparentPixels(sprite.texture), + $"Texture for {kind} has no non-transparent pixels — symbol was not drawn"); + } + + // ------------------------------------------------------------------ + // GetOrCreateFallbackSprite — texture is 64x64 + // ------------------------------------------------------------------ + + [TestCase(ComponentKind.Resistor)] + [TestCase(ComponentKind.VoltageSource)] + [TestCase(ComponentKind.Ground)] + public void GetOrCreateFallbackSprite_TextureIs64x64(ComponentKind kind) + { + var sprite = ComponentSymbolGenerator.GetOrCreateFallbackSprite(kind); + + Assert.AreEqual(64, sprite.texture.width, $"Texture width should be 64 for {kind}"); + Assert.AreEqual(64, sprite.texture.height, $"Texture height should be 64 for {kind}"); + } + + // ------------------------------------------------------------------ + // GetPinDotSprite — non-null, cached, correct texture size + // ------------------------------------------------------------------ + + [Test] + public void GetPinDotSprite_ReturnsNonNull() + { + var sprite = ComponentSymbolGenerator.GetPinDotSprite(); + + Assert.IsNotNull(sprite, "GetPinDotSprite returned null"); + } + + [Test] + public void GetPinDotSprite_TextureIs32x32() + { + var sprite = ComponentSymbolGenerator.GetPinDotSprite(); + + Assert.IsNotNull(sprite.texture, "GetPinDotSprite.texture is null"); + Assert.AreEqual(32, sprite.texture.width, "PinDot texture width should be 32"); + Assert.AreEqual(32, sprite.texture.height, "PinDot texture height should be 32"); + } + + [Test] + public void GetPinDotSprite_CalledTwice_ReturnsSameInstance() + { + var first = ComponentSymbolGenerator.GetPinDotSprite(); + var second = ComponentSymbolGenerator.GetPinDotSprite(); + + Assert.AreSame(first, second, "GetPinDotSprite should return the same cached instance"); + } + + [Test] + public void GetPinDotSprite_TextureHasNonTransparentPixels() + { + var sprite = ComponentSymbolGenerator.GetPinDotSprite(); + + Assert.IsTrue( + HasNonTransparentPixels(sprite.texture), + "PinDot texture has no non-transparent pixels"); + } + + // ------------------------------------------------------------------ + // GetLedGlowSprite — non-null, cached, correct texture size (64x64) + // ------------------------------------------------------------------ + + [Test] + public void GetLedGlowSprite_ReturnsNonNull() + { + var sprite = ComponentSymbolGenerator.GetLedGlowSprite(); + + Assert.IsNotNull(sprite, "GetLedGlowSprite returned null"); + } + + [Test] + public void GetLedGlowSprite_TextureIs64x64() + { + var sprite = ComponentSymbolGenerator.GetLedGlowSprite(); + + Assert.IsNotNull(sprite.texture, "GetLedGlowSprite.texture is null"); + Assert.AreEqual(64, sprite.texture.width, "LedGlow texture width should be 64"); + Assert.AreEqual(64, sprite.texture.height, "LedGlow texture height should be 64"); + } + + [Test] + public void GetLedGlowSprite_CalledTwice_ReturnsSameInstance() + { + var first = ComponentSymbolGenerator.GetLedGlowSprite(); + var second = ComponentSymbolGenerator.GetLedGlowSprite(); + + Assert.AreSame(first, second, "GetLedGlowSprite should return the same cached instance"); + } + + [Test] + public void GetLedGlowSprite_TextureHasNonTransparentPixels() + { + var sprite = ComponentSymbolGenerator.GetLedGlowSprite(); + + Assert.IsTrue( + HasNonTransparentPixels(sprite.texture), + "LedGlow texture has no non-transparent pixels"); + } + + // ------------------------------------------------------------------ + // GetHeatGlowSprite — non-null, cached, correct texture size (64x64) + // ------------------------------------------------------------------ + + [Test] + public void GetHeatGlowSprite_ReturnsNonNull() + { + var sprite = ComponentSymbolGenerator.GetHeatGlowSprite(); + + Assert.IsNotNull(sprite, "GetHeatGlowSprite returned null"); + } + + [Test] + public void GetHeatGlowSprite_TextureIs64x64() + { + var sprite = ComponentSymbolGenerator.GetHeatGlowSprite(); + + Assert.IsNotNull(sprite.texture, "GetHeatGlowSprite.texture is null"); + Assert.AreEqual(64, sprite.texture.width, "HeatGlow texture width should be 64"); + Assert.AreEqual(64, sprite.texture.height, "HeatGlow texture height should be 64"); + } + + [Test] + public void GetHeatGlowSprite_CalledTwice_ReturnsSameInstance() + { + var first = ComponentSymbolGenerator.GetHeatGlowSprite(); + var second = ComponentSymbolGenerator.GetHeatGlowSprite(); + + Assert.AreSame(first, second, "GetHeatGlowSprite should return the same cached instance"); + } + + [Test] + public void GetHeatGlowSprite_TextureHasNonTransparentPixels() + { + var sprite = ComponentSymbolGenerator.GetHeatGlowSprite(); + + Assert.IsTrue( + HasNonTransparentPixels(sprite.texture), + "HeatGlow texture has no non-transparent pixels"); + } + + // ------------------------------------------------------------------ + // PinDotRadius constant + // ------------------------------------------------------------------ + + [Test] + public void PinDotRadius_IsExpectedValue() + { + Assert.AreEqual(0.18f, ComponentSymbolGenerator.PinDotRadius, 0.0001f, + "PinDotRadius should be 0.18f"); + } + + // ------------------------------------------------------------------ + // Cross-sprite independence: different ComponentKinds return different instances + // ------------------------------------------------------------------ + + [Test] + public void GetOrCreateFallbackSprite_DifferentKinds_ReturnDifferentInstances() + { + var resistor = ComponentSymbolGenerator.GetOrCreateFallbackSprite(ComponentKind.Resistor); + var capacitor = ComponentSymbolGenerator.GetOrCreateFallbackSprite(ComponentKind.Capacitor); + var inductor = ComponentSymbolGenerator.GetOrCreateFallbackSprite(ComponentKind.Inductor); + + Assert.AreNotSame(resistor, capacitor, "Resistor and Capacitor should be different Sprite instances"); + Assert.AreNotSame(resistor, inductor, "Resistor and Inductor should be different Sprite instances"); + Assert.AreNotSame(capacitor, inductor, "Capacitor and Inductor should be different Sprite instances"); + } + + // ------------------------------------------------------------------ + // Sprite rect covers the full texture (pivot at center, PPU = texture size) + // ------------------------------------------------------------------ + + [TestCase(ComponentKind.Resistor)] + [TestCase(ComponentKind.BJT)] + public void GetOrCreateFallbackSprite_SpriteRectCoversFullTexture(ComponentKind kind) + { + var sprite = ComponentSymbolGenerator.GetOrCreateFallbackSprite(kind); + + Assert.AreEqual(0f, sprite.rect.x, 0.01f, $"Sprite rect.x should be 0 for {kind}"); + Assert.AreEqual(0f, sprite.rect.y, 0.01f, $"Sprite rect.y should be 0 for {kind}"); + Assert.AreEqual(64f, sprite.rect.width, 0.01f, $"Sprite rect.width should be 64 for {kind}"); + Assert.AreEqual(64f, sprite.rect.height, 0.01f, $"Sprite rect.height should be 64 for {kind}"); + } + } +} diff --git a/Assets/10_Scripts/30_Tests/40_Components/ComponentSymbolGeneratorTests.cs.meta b/Assets/10_Scripts/30_Tests/40_Components/ComponentSymbolGeneratorTests.cs.meta new file mode 100644 index 0000000..9886d6b --- /dev/null +++ b/Assets/10_Scripts/30_Tests/40_Components/ComponentSymbolGeneratorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5d84bd33b35132d40a940e8532d80019 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/50_Controllers.meta b/Assets/10_Scripts/30_Tests/50_Controllers.meta new file mode 100644 index 0000000..d7809e9 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/50_Controllers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 55744b5bf71e4fc4fbaefdfa672a4406 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/50_Controllers/PinDetectorTests.cs b/Assets/10_Scripts/30_Tests/50_Controllers/PinDetectorTests.cs new file mode 100644 index 0000000..8f3772e --- /dev/null +++ b/Assets/10_Scripts/30_Tests/50_Controllers/PinDetectorTests.cs @@ -0,0 +1,160 @@ +using System.Collections.Generic; +using NUnit.Framework; +using CircuitCraft.Core; +using CircuitCraft.Controllers; + +namespace CircuitCraft.Tests.Controllers +{ + [TestFixture] + public class PinDetectorTests + { + private BoardState _board; + + [SetUp] + public void SetUp() + { + _board = new BoardState(20, 20); + } + + private PlacedComponent PlaceComponentAt(GridPosition position, params GridPosition[] pinLocalPositions) + { + var pins = new List(); + for (int i = 0; i < pinLocalPositions.Length; i++) + { + pins.Add(new PinInstance(i, $"pin{i}", pinLocalPositions[i])); + } + + return _board.PlaceComponent("test_component", position, 0, pins); + } + + [Test] + public void TryGetNearestPin_NullComponent_ReturnsFalse() + { + bool found = PinDetector.TryGetNearestPin(null, new GridPosition(0, 0), out PinReference pinRef); + + Assert.IsFalse(found); + Assert.AreEqual(default(PinReference), pinRef); + } + + [Test] + public void TryGetNearestPin_ExactPinPosition_ReturnsTrue() + { + var component = PlaceComponentAt(new GridPosition(5, 7), new GridPosition(0, 0)); + var mousePos = component.GetPinWorldPosition(0); + + bool found = PinDetector.TryGetNearestPin(component, mousePos, out PinReference pinRef); + + Assert.IsTrue(found); + Assert.AreEqual(component.InstanceId, pinRef.ComponentInstanceId); + Assert.AreEqual(0, pinRef.PinIndex); + Assert.AreEqual(mousePos, pinRef.Position); + } + + [Test] + public void TryGetNearestPin_AdjacentPosition_ReturnsTrue() + { + var component = PlaceComponentAt(new GridPosition(10, 10), new GridPosition(0, 0)); + var mousePos = new GridPosition(11, 10); + + bool found = PinDetector.TryGetNearestPin(component, mousePos, out PinReference pinRef); + + Assert.IsTrue(found); + Assert.AreEqual(component.InstanceId, pinRef.ComponentInstanceId); + Assert.AreEqual(0, pinRef.PinIndex); + Assert.AreEqual(new GridPosition(10, 10), pinRef.Position); + } + + [Test] + public void TryGetNearestPin_TooFarAway_ReturnsFalse() + { + var component = PlaceComponentAt(new GridPosition(0, 0), new GridPosition(0, 0)); + var mousePos = new GridPosition(2, 0); + + bool found = PinDetector.TryGetNearestPin(component, mousePos, out PinReference pinRef); + + Assert.IsFalse(found); + Assert.AreEqual(default(PinReference), pinRef); + } + + [Test] + public void TryGetNearestPin_MultiplePins_ReturnsNearest() + { + var component = PlaceComponentAt( + new GridPosition(3, 3), + new GridPosition(0, 0), + new GridPosition(4, 0), + new GridPosition(0, 4)); + var mousePos = new GridPosition(7, 3); + + bool found = PinDetector.TryGetNearestPin(component, mousePos, out PinReference pinRef); + + Assert.IsTrue(found); + Assert.AreEqual(1, pinRef.PinIndex, "Pin 1 should be nearest at world position (7,3)"); + Assert.AreEqual(new GridPosition(7, 3), pinRef.Position); + } + + [Test] + public void TryGetNearestPin_ComponentWithNoPins_ReturnsFalse() + { + var component = _board.PlaceComponent("empty_pins_component", new GridPosition(1, 1), 0, new List()); + + bool found = PinDetector.TryGetNearestPin(component, new GridPosition(1, 1), out PinReference pinRef); + + Assert.IsFalse(found); + Assert.AreEqual(default(PinReference), pinRef); + } + + [Test] + public void TryGetNearestPinFromAll_EmptyList_ReturnsFalse() + { + bool found = PinDetector.TryGetNearestPinFromAll(new List(), new GridPosition(0, 0), out PinReference pinRef); + + Assert.IsFalse(found); + Assert.AreEqual(default(PinReference), pinRef); + } + + [Test] + public void TryGetNearestPinFromAll_MultipleComponents_ReturnsGlobalNearest() + { + var farComponent = PlaceComponentAt(new GridPosition(0, 0), new GridPosition(0, 0)); + var nearComponent = PlaceComponentAt(new GridPosition(8, 8), new GridPosition(0, 0), new GridPosition(1, 0)); + var mousePos = new GridPosition(9, 8); + + bool found = PinDetector.TryGetNearestPinFromAll(_board.Components, mousePos, out PinReference pinRef); + + Assert.IsTrue(found); + Assert.AreEqual(nearComponent.InstanceId, pinRef.ComponentInstanceId); + Assert.AreEqual(1, pinRef.PinIndex); + Assert.AreEqual(new GridPosition(9, 8), pinRef.Position); + Assert.AreNotEqual(farComponent.InstanceId, pinRef.ComponentInstanceId); + } + + [Test] + public void TryGetNearestPinFromAll_AllTooFar_ReturnsFalse() + { + PlaceComponentAt(new GridPosition(0, 0), new GridPosition(0, 0)); + PlaceComponentAt(new GridPosition(10, 10), new GridPosition(0, 0)); + + bool found = PinDetector.TryGetNearestPinFromAll(_board.Components, new GridPosition(5, 5), out PinReference pinRef); + + Assert.IsFalse(found); + Assert.AreEqual(default(PinReference), pinRef); + } + + [Test] + public void TryGetNearestPin_CustomMaxDistance_Respected() + { + var component = PlaceComponentAt(new GridPosition(0, 0), new GridPosition(0, 0)); + var mousePos = new GridPosition(2, 0); + + bool foundWithDefault = PinDetector.TryGetNearestPin(component, mousePos, out _); + bool foundWithCustom = PinDetector.TryGetNearestPin(component, mousePos, out PinReference pinRef, maxDistance: 2); + + Assert.IsFalse(foundWithDefault, "Default maxDistance=1 should reject distance 2."); + Assert.IsTrue(foundWithCustom, "Custom maxDistance=2 should accept distance 2."); + Assert.AreEqual(component.InstanceId, pinRef.ComponentInstanceId); + Assert.AreEqual(0, pinRef.PinIndex); + Assert.AreEqual(new GridPosition(0, 0), pinRef.Position); + } + } +} diff --git a/Assets/10_Scripts/30_Tests/50_Controllers/PinDetectorTests.cs.meta b/Assets/10_Scripts/30_Tests/50_Controllers/PinDetectorTests.cs.meta new file mode 100644 index 0000000..b219ffc --- /dev/null +++ b/Assets/10_Scripts/30_Tests/50_Controllers/PinDetectorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2d7ab9353dda39646889c4ad8c6aac3e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/50_Controllers/PlacementValidatorTests.cs b/Assets/10_Scripts/30_Tests/50_Controllers/PlacementValidatorTests.cs new file mode 100644 index 0000000..2a490fc --- /dev/null +++ b/Assets/10_Scripts/30_Tests/50_Controllers/PlacementValidatorTests.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using CircuitCraft.Controllers; +using CircuitCraft.Core; +using NUnit.Framework; + +namespace CircuitCraft.Tests.Controllers +{ + [TestFixture] + public class PlacementValidatorTests + { + private BoardState _board; + + [SetUp] + public void SetUp() + { + _board = new BoardState(20, 20); + } + + [Test] + public void IsValidPlacement_NullBoardState_ReturnsTrue() + { + var isValid = PlacementValidator.IsValidPlacement(null, new GridPosition(5, 5)); + + Assert.IsTrue(isValid); + } + + [Test] + public void IsValidPlacement_EmptyPosition_ReturnsTrue() + { + var isValid = PlacementValidator.IsValidPlacement(_board, new GridPosition(5, 5)); + + Assert.IsTrue(isValid); + } + + [Test] + public void IsValidPlacement_OccupiedPosition_ReturnsFalse() + { + PlaceAt(5, 5); + + var isValid = PlacementValidator.IsValidPlacement(_board, new GridPosition(5, 5)); + + Assert.IsFalse(isValid); + } + + [Test] + public void IsValidPlacement_DifferentPosition_ReturnsTrue() + { + PlaceAt(5, 5); + + var isValid = PlacementValidator.IsValidPlacement(_board, new GridPosition(6, 5)); + + Assert.IsTrue(isValid); + } + + [Test] + public void IsValidPlacement_AfterRemoval_ReturnsTrue() + { + var instanceId = PlaceAt(5, 5); + var removed = _board.RemoveComponent(instanceId); + + var isValid = PlacementValidator.IsValidPlacement(_board, new GridPosition(5, 5)); + + Assert.IsTrue(removed); + Assert.IsTrue(isValid); + } + + [Test] + public void IsValidPlacement_MultipleComponents_ValidatesCorrectly() + { + PlaceAt(1, 1); + PlaceAt(3, 4); + PlaceAt(10, 12); + + Assert.IsFalse(PlacementValidator.IsValidPlacement(_board, new GridPosition(1, 1))); + Assert.IsFalse(PlacementValidator.IsValidPlacement(_board, new GridPosition(3, 4))); + Assert.IsFalse(PlacementValidator.IsValidPlacement(_board, new GridPosition(10, 12))); + Assert.IsTrue(PlacementValidator.IsValidPlacement(_board, new GridPosition(2, 1))); + Assert.IsTrue(PlacementValidator.IsValidPlacement(_board, new GridPosition(0, 0))); + } + + private int PlaceAt(int x, int y) + { + var placed = _board.PlaceComponent("test", new GridPosition(x, y), 0, new List()); + return placed.InstanceId; + } + } +} diff --git a/Assets/10_Scripts/30_Tests/50_Controllers/PlacementValidatorTests.cs.meta b/Assets/10_Scripts/30_Tests/50_Controllers/PlacementValidatorTests.cs.meta new file mode 100644 index 0000000..a828b68 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/50_Controllers/PlacementValidatorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d2faa67df3f5a084097fc8962779b06b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/50_Controllers/WirePathCalculatorTests.cs b/Assets/10_Scripts/30_Tests/50_Controllers/WirePathCalculatorTests.cs new file mode 100644 index 0000000..2ea8a25 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/50_Controllers/WirePathCalculatorTests.cs @@ -0,0 +1,144 @@ +using CircuitCraft.Controllers; +using CircuitCraft.Core; +using NUnit.Framework; + +namespace CircuitCraft.Tests.Controllers +{ + [TestFixture] + public class WirePathCalculatorTests + { + [Test] + public void BuildManhattanSegments_SameX_ReturnsSingleSegment() + { + var segments = WirePathCalculator.BuildManhattanSegments(new GridPosition(0, 0), new GridPosition(0, 5)); + + Assert.AreEqual(1, segments.Count); + Assert.AreEqual(new GridPosition(0, 0), segments[0].start); + Assert.AreEqual(new GridPosition(0, 5), segments[0].end); + } + + [Test] + public void BuildManhattanSegments_SameY_ReturnsSingleSegment() + { + var segments = WirePathCalculator.BuildManhattanSegments(new GridPosition(0, 3), new GridPosition(5, 3)); + + Assert.AreEqual(1, segments.Count); + Assert.AreEqual(new GridPosition(0, 3), segments[0].start); + Assert.AreEqual(new GridPosition(5, 3), segments[0].end); + } + + [Test] + public void BuildManhattanSegments_DifferentXY_ReturnsTwoSegments() + { + var segments = WirePathCalculator.BuildManhattanSegments(new GridPosition(0, 0), new GridPosition(3, 4)); + + Assert.AreEqual(2, segments.Count); + Assert.AreEqual(new GridPosition(0, 0), segments[0].start); + Assert.AreEqual(new GridPosition(3, 0), segments[0].end); + Assert.AreEqual(new GridPosition(3, 0), segments[1].start); + Assert.AreEqual(new GridPosition(3, 4), segments[1].end); + } + + [Test] + public void BuildManhattanSegments_DifferentXY_CornerIsAtEndXStartY() + { + var start = new GridPosition(2, -1); + var end = new GridPosition(-4, 7); + var segments = WirePathCalculator.BuildManhattanSegments(start, end); + + var expectedCorner = new GridPosition(end.X, start.Y); + Assert.AreEqual(2, segments.Count); + Assert.AreEqual(expectedCorner, segments[0].end); + Assert.AreEqual(expectedCorner, segments[1].start); + } + + [Test] + public void BuildManhattanSegments_SamePoint_ReturnsSingleSegment() + { + var point = new GridPosition(2, 2); + var segments = WirePathCalculator.BuildManhattanSegments(point, point); + + Assert.AreEqual(1, segments.Count); + Assert.AreEqual(point, segments[0].start); + Assert.AreEqual(point, segments[0].end); + } + + [Test] + public void IsPointOnTrace_VerticalTrace_PointOnTrace_ReturnsTrue() + { + var trace = CreateTrace(new GridPosition(2, 1), new GridPosition(2, 5)); + + Assert.IsTrue(WirePathCalculator.IsPointOnTrace(trace, new GridPosition(2, 3))); + } + + [Test] + public void IsPointOnTrace_VerticalTrace_PointOffTrace_ReturnsFalse() + { + var trace = CreateTrace(new GridPosition(2, 1), new GridPosition(2, 5)); + + Assert.IsFalse(WirePathCalculator.IsPointOnTrace(trace, new GridPosition(2, 6))); + } + + [Test] + public void IsPointOnTrace_VerticalTrace_WrongX_ReturnsFalse() + { + var trace = CreateTrace(new GridPosition(2, 1), new GridPosition(2, 5)); + + Assert.IsFalse(WirePathCalculator.IsPointOnTrace(trace, new GridPosition(3, 3))); + } + + [Test] + public void IsPointOnTrace_HorizontalTrace_PointOnTrace_ReturnsTrue() + { + var trace = CreateTrace(new GridPosition(1, 4), new GridPosition(5, 4)); + + Assert.IsTrue(WirePathCalculator.IsPointOnTrace(trace, new GridPosition(3, 4))); + } + + [Test] + public void IsPointOnTrace_HorizontalTrace_PointOffTrace_ReturnsFalse() + { + var trace = CreateTrace(new GridPosition(1, 4), new GridPosition(5, 4)); + + Assert.IsFalse(WirePathCalculator.IsPointOnTrace(trace, new GridPosition(6, 4))); + } + + [Test] + public void IsPointOnTrace_HorizontalTrace_WrongY_ReturnsFalse() + { + var trace = CreateTrace(new GridPosition(1, 4), new GridPosition(5, 4)); + + Assert.IsFalse(WirePathCalculator.IsPointOnTrace(trace, new GridPosition(3, 5))); + } + + [Test] + public void IsPointOnTrace_PointAtStart_ReturnsTrue() + { + var trace = CreateTrace(new GridPosition(1, 4), new GridPosition(5, 4)); + + Assert.IsTrue(WirePathCalculator.IsPointOnTrace(trace, trace.Start)); + } + + [Test] + public void IsPointOnTrace_PointAtEnd_ReturnsTrue() + { + var trace = CreateTrace(new GridPosition(1, 4), new GridPosition(5, 4)); + + Assert.IsTrue(WirePathCalculator.IsPointOnTrace(trace, trace.End)); + } + + [Test] + public void IsPointOnTrace_ReversedStartEnd_StillWorks() + { + var trace = CreateTrace(new GridPosition(5, 4), new GridPosition(1, 4)); + + Assert.IsTrue(WirePathCalculator.IsPointOnTrace(trace, new GridPosition(3, 4))); + Assert.IsFalse(WirePathCalculator.IsPointOnTrace(trace, new GridPosition(0, 4))); + } + + private static TraceSegment CreateTrace(GridPosition start, GridPosition end) + { + return new TraceSegment(1, 1, start, end); + } + } +} diff --git a/Assets/10_Scripts/30_Tests/50_Controllers/WirePathCalculatorTests.cs.meta b/Assets/10_Scripts/30_Tests/50_Controllers/WirePathCalculatorTests.cs.meta new file mode 100644 index 0000000..d1d1f0c --- /dev/null +++ b/Assets/10_Scripts/30_Tests/50_Controllers/WirePathCalculatorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 44dbe22a01c5ad24fa160bb1201d21db +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/60_Views.meta b/Assets/10_Scripts/30_Tests/60_Views.meta new file mode 100644 index 0000000..380ba2c --- /dev/null +++ b/Assets/10_Scripts/30_Tests/60_Views.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 76a7b5aa4c457e341903f2a090e3c32b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/60_Views/SimulationDataMapperTests.cs b/Assets/10_Scripts/30_Tests/60_Views/SimulationDataMapperTests.cs new file mode 100644 index 0000000..78f0b08 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/60_Views/SimulationDataMapperTests.cs @@ -0,0 +1,502 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using CircuitCraft.Components; +using CircuitCraft.Core; +using CircuitCraft.Data; +using CircuitCraft.Simulation; +using CircuitCraft.Views; +using NUnit.Framework; +using UnityEngine; + +namespace CircuitCraft.Tests.Views +{ + [TestFixture] + public class SimulationDataMapperTests + { + private readonly List _createdObjects = new(); + + [TearDown] + public void TearDown() + { + foreach (var obj in _createdObjects) + { + if (obj != null) + { + UnityEngine.Object.DestroyImmediate(obj); + } + } + + _createdObjects.Clear(); + } + + [Test] + public void ExtractNodeVoltages_NullResult_ReturnsEmpty() + { + var nodeVoltages = SimulationDataMapper.ExtractNodeVoltages(null); + + Assert.IsNotNull(nodeVoltages); + Assert.AreEqual(0, nodeVoltages.Count); + } + + [Test] + public void ExtractNodeVoltages_NullProbeResults_ReturnsEmpty() + { + var result = new SimulationResult { ProbeResults = null }; + + var nodeVoltages = SimulationDataMapper.ExtractNodeVoltages(result); + + Assert.IsNotNull(nodeVoltages); + Assert.AreEqual(0, nodeVoltages.Count); + } + + [Test] + public void ExtractNodeVoltages_EmptyProbeResults_ReturnsEmpty() + { + var result = new SimulationResult(); + + var nodeVoltages = SimulationDataMapper.ExtractNodeVoltages(result); + + Assert.AreEqual(0, nodeVoltages.Count); + } + + [Test] + public void ExtractNodeVoltages_NonVoltageProbes_AreFilteredOut() + { + var result = new SimulationResult + { + ProbeResults = new List + { + new("I_R1", ProbeType.Current, "R1", 0.002), + new("P_R1", ProbeType.Power, "R1", 0.0004) + } + }; + + var nodeVoltages = SimulationDataMapper.ExtractNodeVoltages(result); + + Assert.AreEqual(0, nodeVoltages.Count); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("\t")] + public void ExtractNodeVoltages_WhitespaceOrNullTargets_AreFilteredOut(string target) + { + var result = new SimulationResult + { + ProbeResults = new List + { + new("V_target", ProbeType.Voltage, target, 1.23) + } + }; + + var nodeVoltages = SimulationDataMapper.ExtractNodeVoltages(result); + + Assert.AreEqual(0, nodeVoltages.Count); + } + + [Test] + public void ExtractNodeVoltages_ValidVoltageProbes_AreExtracted() + { + var result = new SimulationResult + { + ProbeResults = new List + { + new("V_a", ProbeType.Voltage, "A", 3.3), + new("V_b", ProbeType.Voltage, "B", 1.1), + new("I_r1", ProbeType.Current, "R1", 0.002) + } + }; + + var nodeVoltages = SimulationDataMapper.ExtractNodeVoltages(result); + + Assert.AreEqual(2, nodeVoltages.Count); + Assert.AreEqual(3.3, nodeVoltages["A"]); + Assert.AreEqual(1.1, nodeVoltages["B"]); + } + + [Test] + public void GetComponentCurrent_NullResult_ReturnsNull() + { + var definition = CreateDefinition(ComponentKind.Resistor, 1000f); + + var current = SimulationDataMapper.GetComponentCurrent(definition, 1, null); + + Assert.IsNull(current); + } + + [Test] + public void GetComponentCurrent_NullDefinition_ReturnsNull() + { + var result = MakeCurrentResult("R1", 0.01); + + var current = SimulationDataMapper.GetComponentCurrent(null, 1, result); + + Assert.IsNull(current); + } + + [Test] + public void GetComponentCurrent_GroundKind_ReturnsNull() + { + var definition = CreateDefinition(ComponentKind.Ground); + var result = MakeCurrentResult("X1", 0.01); + + var current = SimulationDataMapper.GetComponentCurrent(definition, 1, result); + + Assert.IsNull(current); + } + + [Test] + public void GetComponentCurrent_ProbeKind_ReturnsNull() + { + var definition = CreateDefinition(ComponentKind.Probe); + var result = MakeCurrentResult("X1", 0.01); + + var current = SimulationDataMapper.GetComponentCurrent(definition, 1, result); + + Assert.IsNull(current); + } + + [Test] + public void GetComponentCurrent_ResistorKind_UsesElementPrefix() + { + var definition = CreateDefinition(ComponentKind.Resistor, 1000f); + var result = MakeCurrentResult("R5", 0.004); + + var current = SimulationDataMapper.GetComponentCurrent(definition, 5, result); + + Assert.AreEqual(0.004, current); + } + + [Test] + public void GetComponentCurrent_LedKind_UsesElementPrefix() + { + var definition = CreateDefinition(ComponentKind.LED); + var result = MakeCurrentResult("D7", 0.0025); + + var current = SimulationDataMapper.GetComponentCurrent(definition, 7, result); + + Assert.AreEqual(0.0025, current); + } + + [Test] + public void GetComponentCurrent_VoltageSourceKind_UsesElementPrefix() + { + var definition = CreateDefinition(ComponentKind.VoltageSource); + var result = MakeCurrentResult("V2", 0.015); + + var current = SimulationDataMapper.GetComponentCurrent(definition, 2, result); + + Assert.AreEqual(0.015, current); + } + + [TestCase(0.005f, 0, 0.005f)] + [TestCase(-0.005f, 0, -0.005f)] + [TestCase(0.005f, 1, -0.005f)] + [TestCase(-0.005f, 1, 0.005f)] + [TestCase(0.005f, 2, 0f)] + [TestCase(0.005f, 3, 0f)] + public void GetPinSignedCurrentContribution_ReturnsExpected(float current, int pinIndex, float expected) + { + var contribution = SimulationDataMapper.GetPinSignedCurrentContribution(current, pinIndex); + + Assert.AreEqual(expected, contribution); + } + + [Test] + public void ResolveResistorValue_ComponentCustomValue_HasPriority() + { + var definition = CreateDefinition(ComponentKind.Resistor, 220f); + var component = CreatePlacedComponent(customValue: 330f); + + var resistance = SimulationDataMapper.ResolveResistorValue(definition, component); + + Assert.AreEqual(330f, resistance); + } + + [Test] + public void ResolveResistorValue_NoCustomValue_UsesDefinitionResistance() + { + var definition = CreateDefinition(ComponentKind.Resistor, 680f); + var component = CreatePlacedComponent(); + + var resistance = SimulationDataMapper.ResolveResistorValue(definition, component); + + Assert.AreEqual(680f, resistance); + } + + [Test] + public void ResolveResistorValue_ZeroDefinitionResistance_UsesFallback() + { + var definition = CreateDefinition(ComponentKind.Resistor, 0f); + var component = CreatePlacedComponent(); + + var resistance = SimulationDataMapper.ResolveResistorValue(definition, component); + + Assert.AreEqual(1000f, resistance); + } + + [Test] + public void ResolveResistorValue_NullDefinition_ReturnsZero() + { + var component = CreatePlacedComponent(); + + var resistance = SimulationDataMapper.ResolveResistorValue(null, component); + + Assert.AreEqual(0f, resistance); + } + + [Test] + public void ResolveResistorValue_NullComponent_UsesDefinitionResistance() + { + var definition = CreateDefinition(ComponentKind.Resistor, 470f); + + var resistance = SimulationDataMapper.ResolveResistorValue(definition, null); + + Assert.AreEqual(470f, resistance); + } + + [Test] + public void BuildTraceSegmentCurrentMap_EmptyTraces_ReturnsEmpty() + { + var board = new BoardState(10, 10); + var map = SimulationDataMapper.BuildTraceSegmentCurrentMap( + board.Traces, + new Dictionary(), + board, + new SimulationResult(), + 1e-6f); + + Assert.AreEqual(0, map.Count); + } + + [Test] + public void BuildTraceSegmentCurrentMap_NoMatchingNetCurrent_ReturnsEmpty() + { + var board = new BoardState(10, 10); + var traceNet = board.CreateNet("TRACE_NET"); + var sourceNet = board.CreateNet("SOURCE_NET"); + board.AddTrace(traceNet.NetId, new GridPosition(0, 0), new GridPosition(1, 0)); + + var definition = CreateDefinition(ComponentKind.Resistor, 1000f); + var component = board.PlaceComponent("res", new GridPosition(2, 0), 0, CreatePins(2)); + ConnectPin(board, sourceNet, component, 0); + + var view = CreateComponentView(definition); + var componentViews = new Dictionary { [component.InstanceId] = view }; + var result = MakeCurrentResult($"R{component.InstanceId}", 0.01); + + var map = SimulationDataMapper.BuildTraceSegmentCurrentMap(board.Traces, componentViews, board, result, 1e-6f); + + Assert.AreEqual(0, map.Count); + } + + [Test] + public void BuildTraceSegmentCurrentMap_BelowThreshold_IsFilteredOut() + { + var board = new BoardState(10, 10); + var net = board.CreateNet("N1"); + var trace = board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(1, 0)); + + var definition = CreateDefinition(ComponentKind.Resistor, 1000f); + var component = board.PlaceComponent("res", new GridPosition(2, 0), 0, CreatePins(2)); + ConnectPin(board, net, component, 0); + + var view = CreateComponentView(definition); + var componentViews = new Dictionary { [component.InstanceId] = view }; + var result = MakeCurrentResult($"R{component.InstanceId}", 0.002); + + var map = SimulationDataMapper.BuildTraceSegmentCurrentMap(board.Traces, componentViews, board, result, 0.01f); + + Assert.IsFalse(map.ContainsKey(trace.SegmentId)); + } + + [Test] + public void BuildTraceSegmentCurrentMap_ValidNetCurrent_MapsTraceSegment() + { + var board = new BoardState(10, 10); + var net = board.CreateNet("N1"); + var trace = board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(1, 0)); + + var definition = CreateDefinition(ComponentKind.Resistor, 1000f); + var component = board.PlaceComponent("res", new GridPosition(2, 0), 0, CreatePins(2)); + ConnectPin(board, net, component, 0); + + var view = CreateComponentView(definition); + var componentViews = new Dictionary { [component.InstanceId] = view }; + var result = MakeCurrentResult($"R{component.InstanceId}", 0.005); + + var map = SimulationDataMapper.BuildTraceSegmentCurrentMap(board.Traces, componentViews, board, result, 1e-6f); + + Assert.IsTrue(map.ContainsKey(trace.SegmentId)); + Assert.AreEqual(0.005f, map[trace.SegmentId], 1e-7f); + } + + [Test] + public void BuildTraceSegmentCurrentMap_CancelledSignedCurrent_UsesFallbackCurrent() + { + var board = new BoardState(10, 10); + var net = board.CreateNet("N1"); + var trace = board.AddTrace(net.NetId, new GridPosition(0, 0), new GridPosition(1, 0)); + + var definition = CreateDefinition(ComponentKind.Resistor, 1000f); + var component = board.PlaceComponent("res", new GridPosition(2, 0), 0, CreatePins(2)); + ConnectPin(board, net, component, 0); + ConnectPin(board, net, component, 1); + + var view = CreateComponentView(definition); + var componentViews = new Dictionary { [component.InstanceId] = view }; + var result = MakeCurrentResult($"R{component.InstanceId}", 0.02); + + var map = SimulationDataMapper.BuildTraceSegmentCurrentMap(board.Traces, componentViews, board, result, 1e-6f); + + Assert.IsTrue(map.ContainsKey(trace.SegmentId)); + Assert.AreEqual(0.02f, map[trace.SegmentId], 1e-7f); + } + + [Test] + public void GetResistorPowerMap_NoResistors_ReturnsEmpty() + { + var board = new BoardState(10, 10); + var net = board.CreateNet("N1"); + var component = board.PlaceComponent("led", new GridPosition(0, 0), 0, CreatePins(2)); + ConnectPin(board, net, component, 0); + + var definition = CreateDefinition(ComponentKind.LED); + var view = CreateComponentView(definition); + var componentViews = new Dictionary { [component.InstanceId] = view }; + var result = MakeCurrentResult($"D{component.InstanceId}", 0.01); + + var map = SimulationDataMapper.GetResistorPowerMap(componentViews, board, result); + + Assert.AreEqual(0, map.Count); + } + + [Test] + public void GetResistorPowerMap_ResistorWithCurrent_ComputesI2R() + { + var board = new BoardState(10, 10); + var net = board.CreateNet("N1"); + var component = board.PlaceComponent("res", new GridPosition(0, 0), 0, CreatePins(2)); + ConnectPin(board, net, component, 0); + + var definition = CreateDefinition(ComponentKind.Resistor, 200f); + var view = CreateComponentView(definition); + var componentViews = new Dictionary { [component.InstanceId] = view }; + var result = MakeCurrentResult($"R{component.InstanceId}", 0.05); + + var map = SimulationDataMapper.GetResistorPowerMap(componentViews, board, result); + + Assert.AreEqual(1, map.Count); + Assert.AreEqual(0.05 * 0.05 * 200.0, map[component.InstanceId], 1e-9); + } + + [Test] + public void GetResistorPowerMap_CustomValueResistance_HasPriority() + { + var board = new BoardState(10, 10); + var net = board.CreateNet("N1"); + var component = board.PlaceComponent("res", new GridPosition(0, 0), 0, CreatePins(2), customValue: 330f); + ConnectPin(board, net, component, 0); + + var definition = CreateDefinition(ComponentKind.Resistor, 100f); + var view = CreateComponentView(definition); + var componentViews = new Dictionary { [component.InstanceId] = view }; + var result = MakeCurrentResult($"R{component.InstanceId}", 0.1); + + var map = SimulationDataMapper.GetResistorPowerMap(componentViews, board, result); + + Assert.AreEqual(1, map.Count); + Assert.AreEqual(0.1 * 0.1 * 330.0, map[component.InstanceId], 1e-9); + } + + [Test] + public void GetResistorPowerMap_NonPositiveResistance_IsSkipped() + { + var board = new BoardState(10, 10); + var net = board.CreateNet("N1"); + var component = board.PlaceComponent("res", new GridPosition(0, 0), 0, CreatePins(2), customValue: -5f); + ConnectPin(board, net, component, 0); + + var definition = CreateDefinition(ComponentKind.Resistor, 220f); + var view = CreateComponentView(definition); + var componentViews = new Dictionary { [component.InstanceId] = view }; + var result = MakeCurrentResult($"R{component.InstanceId}", 0.1); + + var map = SimulationDataMapper.GetResistorPowerMap(componentViews, board, result); + + Assert.AreEqual(0, map.Count); + } + + private SimulationResult MakeCurrentResult(string elementId, double current) + { + return new SimulationResult + { + ProbeResults = new List + { + new($"I_{elementId}", ProbeType.Current, elementId, current) + } + }; + } + + private ComponentDefinition CreateDefinition(ComponentKind kind, float resistance = 0f) + { + var definition = ScriptableObject.CreateInstance(); + _createdObjects.Add(definition); + + SetPrivateField(definition, "_kind", kind); + SetPrivateField(definition, "_resistanceOhms", resistance); + + return definition; + } + + private ComponentView CreateComponentView(ComponentDefinition definition) + { + var gameObject = new GameObject($"View_{definition.Kind}"); + _createdObjects.Add(gameObject); + var view = gameObject.AddComponent(); + view.Initialize(definition); + return view; + } + + private static PlacedComponent CreatePlacedComponent(float? customValue = null) + { + return new PlacedComponent(1, "res", new GridPosition(0, 0), 0, CreatePins(2), customValue); + } + + private static List CreatePins(int count) + { + var pins = new List(); + for (int i = 0; i < count; i++) + { + pins.Add(new PinInstance(i, $"pin{i}", new GridPosition(i, 0))); + } + + return pins; + } + + private static void ConnectPin(BoardState board, Net net, PlacedComponent component, int pinIndex) + { + board.ConnectPinToNet(net.NetId, new PinReference(component.InstanceId, pinIndex, component.GetPinWorldPosition(pinIndex))); + } + + private static void SetPrivateField(T target, string fieldName, object value) where T : class + { + var type = target.GetType(); + while (type is not null) + { + var field = type.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + if (field is not null) + { + field.SetValue(target, value); + return; + } + + type = type.BaseType; + } + + throw new MissingFieldException(target.GetType().Name, fieldName); + } + } +} diff --git a/Assets/10_Scripts/30_Tests/60_Views/SimulationDataMapperTests.cs.meta b/Assets/10_Scripts/30_Tests/60_Views/SimulationDataMapperTests.cs.meta new file mode 100644 index 0000000..949c99b --- /dev/null +++ b/Assets/10_Scripts/30_Tests/60_Views/SimulationDataMapperTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 829d7f1a278f07046bfa8f0d43dbc10e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/60_Views/TraceGeometryBuilderTests.cs b/Assets/10_Scripts/30_Tests/60_Views/TraceGeometryBuilderTests.cs new file mode 100644 index 0000000..74b94c9 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/60_Views/TraceGeometryBuilderTests.cs @@ -0,0 +1,318 @@ +using System; +using System.Collections.Generic; +using CircuitCraft.Core; +using CircuitCraft.Views; +using NUnit.Framework; +using UnityEngine; + +namespace CircuitCraft.Tests.Views +{ + [TestFixture] + public class TraceGeometryBuilderTests + { + [TestCase(0f, 0f, 10f, 0f)] + [TestCase(5f, 0f, 10f, 0.5f)] + [TestCase(10f, 0f, 10f, 1f)] + [TestCase(-5f, 0f, 10f, 0f)] + [TestCase(15f, 0f, 10f, 1f)] + public void NormalizeVoltage_NormalRangeAndClamping_ReturnsExpected(float voltage, float min, float max, float expected) + { + var normalized = TraceGeometryBuilder.NormalizeVoltage(voltage, min, max); + + Assert.AreEqual(expected, normalized, 0.000001f); + } + + [Test] + public void NormalizeVoltage_ZeroRange_ReturnsZero() + { + var normalized = TraceGeometryBuilder.NormalizeVoltage(4f, 4f, 4f); + + Assert.AreEqual(0f, normalized, 0.000001f); + } + + [Test] + public void NormalizeVoltage_EpsilonRange_ReturnsClampedValue() + { + float min = 2f; + float max = min + (float.Epsilon * 2f); + + var normalized = TraceGeometryBuilder.NormalizeVoltage(min + float.Epsilon, min, max); + + Assert.AreEqual(0.5f, normalized, 0.0001f); + } + + [Test] + public void GenerateFlowTexturePixels_WidthTimesHeight_ReturnsExpectedLength() + { + const int width = 64; + const int height = 8; + var pixels = TraceGeometryBuilder.GenerateFlowTexturePixels(width, height); + + Assert.AreEqual(width * height, pixels.Length); + } + + [Test] + public void GenerateFlowTexturePixels_EdgeRows_HaveZeroAlpha() + { + const int width = 64; + const int height = 8; + var pixels = TraceGeometryBuilder.GenerateFlowTexturePixels(width, height); + + var top = GetPixel(pixels, width, 16, 0); + var bottom = GetPixel(pixels, width, 16, height - 1); + + Assert.AreEqual(0f, top.a, 0.000001f); + Assert.AreEqual(0f, bottom.a, 0.000001f); + } + + [Test] + public void GenerateFlowTexturePixels_PeakColumn_CenterRowHasExpectedAlpha() + { + const int width = 64; + const int height = 9; + var pixels = TraceGeometryBuilder.GenerateFlowTexturePixels(width, height); + + var center = GetPixel(pixels, width, 16, 4); + + Assert.AreEqual(1f, center.a, 0.000001f); + Assert.AreEqual(1f, center.r, 0.000001f); + Assert.AreEqual(1f, center.g, 0.000001f); + Assert.AreEqual(1f, center.b, 0.000001f); + } + + [Test] + public void GenerateFlowTexturePixels_ValleyColumn_CenterRowHasZeroAlpha() + { + const int width = 64; + const int height = 9; + var pixels = TraceGeometryBuilder.GenerateFlowTexturePixels(width, height); + + var center = GetPixel(pixels, width, 0, 4); + + Assert.AreEqual(0f, center.a, 0.000001f); + } + + [Test] + public void GenerateFlowTexturePixels_PeaksRepeatAcrossTexture() + { + const int width = 64; + const int height = 9; + var pixels = TraceGeometryBuilder.GenerateFlowTexturePixels(width, height); + + var firstPeak = GetPixel(pixels, width, 16, 4); + var secondPeak = GetPixel(pixels, width, 48, 4); + + Assert.AreEqual(firstPeak.a, secondPeak.a, 0.000001f); + } + + [Test] + public void ComputeVoltageColors_EmptyTraces_ReturnsEmptyMap() + { + var colors = TraceGeometryBuilder.ComputeVoltageColors( + Array.Empty(), + _ => null, + new Dictionary(), + Color.blue, + Color.yellow, + Color.white); + + Assert.AreEqual(0, colors.Count); + } + + [Test] + public void ComputeVoltageColors_NullVoltages_UsesDefaultColor() + { + var traces = new[] + { + new TraceSegment(1, 1, new GridPosition(0, 0), new GridPosition(0, 1)) + }; + + var colors = TraceGeometryBuilder.ComputeVoltageColors( + traces, + _ => new Net(1, "N1"), + null, + Color.blue, + Color.yellow, + Color.cyan); + + AssertColorEqual(Color.cyan, colors[1]); + } + + [Test] + public void ComputeVoltageColors_MissingNet_UsesDefaultColor() + { + var traces = new[] + { + new TraceSegment(1, 1, new GridPosition(0, 0), new GridPosition(0, 1)) + }; + + var colors = TraceGeometryBuilder.ComputeVoltageColors( + traces, + _ => null, + new Dictionary { ["N1"] = 5.0 }, + Color.blue, + Color.yellow, + Color.white); + + AssertColorEqual(Color.white, colors[1]); + } + + [Test] + public void ComputeVoltageColors_MissingNodeVoltage_UsesDefaultColor() + { + var traces = new[] + { + new TraceSegment(1, 1, new GridPosition(0, 0), new GridPosition(0, 1)) + }; + + var colors = TraceGeometryBuilder.ComputeVoltageColors( + traces, + _ => new Net(1, "N1"), + new Dictionary { ["N2"] = 5.0 }, + Color.blue, + Color.yellow, + Color.magenta); + + AssertColorEqual(Color.magenta, colors[1]); + } + + [Test] + public void ComputeVoltageColors_ValidVoltages_MapsMinAndMaxColors() + { + var traces = new[] + { + new TraceSegment(1, 1, new GridPosition(0, 0), new GridPosition(0, 1)), + new TraceSegment(2, 2, new GridPosition(1, 0), new GridPosition(1, 1)) + }; + var netMap = new Dictionary + { + [1] = new Net(1, "LOW"), + [2] = new Net(2, "HIGH") + }; + var nodeVoltages = new Dictionary + { + ["LOW"] = 0.0, + ["HIGH"] = 10.0 + }; + + var colors = TraceGeometryBuilder.ComputeVoltageColors( + traces, + netId => netMap[netId], + nodeVoltages, + Color.blue, + Color.yellow, + Color.white); + + AssertColorEqual(Color.blue, colors[1]); + AssertColorEqual(Color.yellow, colors[2]); + } + + [Test] + public void ComputeVoltageColors_ValidMidVoltage_MapsLerpedColor() + { + var traces = new[] + { + new TraceSegment(1, 1, new GridPosition(0, 0), new GridPosition(0, 1)) + }; + + var colors = TraceGeometryBuilder.ComputeVoltageColors( + traces, + _ => new Net(1, "MID"), + new Dictionary + { + ["LOW"] = 0.0, + ["MID"] = 5.0, + ["HIGH"] = 10.0 + }, + Color.blue, + Color.yellow, + Color.white); + + var expected = Color.Lerp(Color.blue, Color.yellow, 0.5f); + AssertColorEqual(expected, colors[1]); + } + + [Test] + public void CalculateFlowOffset_ZeroCurrent_OnlyWrapsCurrentOffset() + { + var offset = TraceGeometryBuilder.CalculateFlowOffset( + currentOffset: 1.2f, + current: 0f, + baseSpeed: 0.2f, + speedScale: 2f, + maxSpeed: 2.2f, + deltaTime: 1f); + + Assert.AreEqual(0.2f, offset, 0.000001f); + } + + [Test] + public void CalculateFlowOffset_PositiveCurrent_IncreasesOffset() + { + var offset = TraceGeometryBuilder.CalculateFlowOffset( + currentOffset: 0.1f, + current: 0.5f, + baseSpeed: 0.2f, + speedScale: 2f, + maxSpeed: 2.2f, + deltaTime: 1f); + + Assert.AreEqual(0.3f, offset, 0.000001f); + } + + [Test] + public void CalculateFlowOffset_NegativeCurrent_DecreasesAndWrapsOffset() + { + var offset = TraceGeometryBuilder.CalculateFlowOffset( + currentOffset: 0.1f, + current: -0.5f, + baseSpeed: 0.2f, + speedScale: 2f, + maxSpeed: 2.2f, + deltaTime: 1f); + + Assert.AreEqual(0.9f, offset, 0.000001f); + } + + [Test] + public void CalculateFlowOffset_HighCurrent_ClampsSpeedToMax() + { + var offset = TraceGeometryBuilder.CalculateFlowOffset( + currentOffset: 0f, + current: 100f, + baseSpeed: 0.2f, + speedScale: 2f, + maxSpeed: 1.5f, + deltaTime: 1f); + + Assert.AreEqual(0.5f, offset, 0.000001f); + } + + [Test] + public void CalculateFlowOffset_LargeDeltaTime_WrapsUsingRepeat() + { + var offset = TraceGeometryBuilder.CalculateFlowOffset( + currentOffset: 0.3f, + current: 1f, + baseSpeed: 0.2f, + speedScale: 2f, + maxSpeed: 2.2f, + deltaTime: 2f); + + Assert.AreEqual(0.7f, offset, 0.000001f); + } + + private static Color GetPixel(IReadOnlyList pixels, int width, int x, int y) + { + return pixels[(y * width) + x]; + } + + private static void AssertColorEqual(Color expected, Color actual) + { + Assert.AreEqual(expected.r, actual.r, 0.000001f); + Assert.AreEqual(expected.g, actual.g, 0.000001f); + Assert.AreEqual(expected.b, actual.b, 0.000001f); + Assert.AreEqual(expected.a, actual.a, 0.000001f); + } + } +} diff --git a/Assets/10_Scripts/30_Tests/60_Views/TraceGeometryBuilderTests.cs.meta b/Assets/10_Scripts/30_Tests/60_Views/TraceGeometryBuilderTests.cs.meta new file mode 100644 index 0000000..0c58ed0 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/60_Views/TraceGeometryBuilderTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 80cdea1e1abdf174fb682342702dd597 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/70_Managers.meta b/Assets/10_Scripts/30_Tests/70_Managers.meta new file mode 100644 index 0000000..8ae9cb1 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/70_Managers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1643138933f452e4e805f3fd81f919d5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/70_Managers/ProgressionManagerTests.cs b/Assets/10_Scripts/30_Tests/70_Managers/ProgressionManagerTests.cs new file mode 100644 index 0000000..6f4ec60 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/70_Managers/ProgressionManagerTests.cs @@ -0,0 +1,643 @@ +using System.Collections.Generic; +using System.Reflection; +using NUnit.Framework; +using CircuitCraft.Core; +using CircuitCraft.Data; +using CircuitCraft.Managers; +using UnityEngine; + +namespace CircuitCraft.Tests.Managers +{ + /// + /// Characterization tests for ProgressionManager — captures ALL observable public-surface + /// behaviors as a safety net before refactoring. + /// PlayerPrefs keys written during tests are cleaned up in TearDown. + /// + [TestFixture] + public class ProgressionManagerTests + { + // Track all created Unity objects for cleanup + private readonly List _createdObjects = new List(); + // Track PlayerPrefs keys to delete in TearDown + private readonly List _playerPrefsKeys = new List(); + + private GameObject _progressionManagerGo; + private GameObject _stageManagerGo; + private GameObject _gameManagerGo; + private GameObject _simulationManagerGo; + + private ProgressionManager _progressionManager; + private StageManager _stageManager; + private GameManager _gameManager; + private SimulationManager _simulationManager; + + [SetUp] + public void SetUp() + { + // Create GameObjects + _simulationManagerGo = new GameObject("TestSimulationManager"); + _createdObjects.Add(_simulationManagerGo); + + _gameManagerGo = new GameObject("TestGameManager"); + _createdObjects.Add(_gameManagerGo); + + _stageManagerGo = new GameObject("TestStageManager"); + _createdObjects.Add(_stageManagerGo); + + _progressionManagerGo = new GameObject("TestProgressionManager"); + _createdObjects.Add(_progressionManagerGo); + + // AddComponent triggers Awake + _simulationManager = _simulationManagerGo.AddComponent(); + _gameManager = _gameManagerGo.AddComponent(); + _stageManager = _stageManagerGo.AddComponent(); + + // Wire StageManager dependencies + SetPrivateField(_stageManager, "_gameManager", _gameManager); + SetPrivateField(_stageManager, "_simulationManager", _simulationManager); + + // ProgressionManager.Awake calls InitializeDefaults + LoadProgress. + // We set _allStages and _stageManager BEFORE AddComponent so Awake picks them up. + // But since MonoBehaviour fields are set after construction, we use a deferred approach: + // Create the GO, add the component (Awake fires with null fields, which is safe), + // then wire fields and call InitializeDefaults via reflection to reset state. + _progressionManager = _progressionManagerGo.AddComponent(); + SetPrivateField(_progressionManager, "_stageManager", _stageManager); + } + + [TearDown] + public void TearDown() + { + // Clean up PlayerPrefs keys created during tests + foreach (var key in _playerPrefsKeys) + { + PlayerPrefs.DeleteKey(key); + } + _playerPrefsKeys.Clear(); + PlayerPrefs.Save(); + + foreach (var obj in _createdObjects) + { + if (obj != null) + UnityEngine.Object.DestroyImmediate(obj); + } + _createdObjects.Clear(); + } + + // ------------------------------------------------------------------ + // Helper Methods + // ------------------------------------------------------------------ + + private static void SetPrivateField(object target, string fieldName, T value) + { + var field = target.GetType().GetField( + fieldName, + BindingFlags.NonPublic | BindingFlags.Instance); + Assert.IsNotNull(field, $"Field '{fieldName}' not found on {target.GetType().Name}"); + field.SetValue(target, value); + } + + private StageDefinition CreateStageDefinition( + string stageId, + string worldId, + int stageNumber, + int targetArea = 4) + { + var stage = ScriptableObject.CreateInstance(); + _createdObjects.Add(stage); + SetPrivateField(stage, "_stageId", stageId); + SetPrivateField(stage, "_displayName", stageId); + SetPrivateField(stage, "_worldId", worldId); + SetPrivateField(stage, "_stageNumber", stageNumber); + SetPrivateField(stage, "_targetArea", targetArea); + + // Register PlayerPrefs keys for cleanup + _playerPrefsKeys.Add($"progress_unlock_{stageId}"); + _playerPrefsKeys.Add($"progress_stars_{stageId}"); + + return stage; + } + + /// + /// Configures _allStages and re-runs InitializeDefaults + LoadProgress + /// so the ProgressionManager starts with correct initial state for each test. + /// + private void SetupAllStages(StageDefinition[] stages) + { + SetPrivateField(_progressionManager, "_allStages", stages); + + // Reset internal dictionaries before re-initializing + var unlockedField = typeof(ProgressionManager).GetField( + "_unlockedStages", + BindingFlags.NonPublic | BindingFlags.Instance); + var bestStarsField = typeof(ProgressionManager).GetField( + "_bestStars", + BindingFlags.NonPublic | BindingFlags.Instance); + + ((Dictionary)unlockedField.GetValue(_progressionManager)).Clear(); + ((Dictionary)bestStarsField.GetValue(_progressionManager)).Clear(); + + // Re-run initialization with the new stages + var initMethod = typeof(ProgressionManager).GetMethod( + "InitializeDefaults", + BindingFlags.NonPublic | BindingFlags.Instance); + initMethod.Invoke(_progressionManager, null); + } + + private ScoreBreakdown CreatePassingBreakdown(int stars) + { + return new ScoreBreakdown( + baseScore: 1000, + budgetBonus: 0, + areaBonus: 0, + totalScore: 1000, + stars: stars, + passed: true, + lineItems: new List(), + summary: $"PASSED — {stars} stars"); + } + + private ScoreBreakdown CreateFailingBreakdown() + { + return new ScoreBreakdown( + baseScore: 0, + budgetBonus: 0, + areaBonus: 0, + totalScore: 0, + stars: 0, + passed: false, + lineItems: new List(), + summary: "FAILED"); + } + + // ------------------------------------------------------------------ + // IsStageUnlocked + // ------------------------------------------------------------------ + + [Test] + public void IsStageUnlocked_UnknownStageId_ReturnsFalse() + { + Assert.IsFalse(_progressionManager.IsStageUnlocked("nonexistent_stage")); + } + + [Test] + public void IsStageUnlocked_NullStageId_ReturnsFalse() + { + Assert.IsFalse(_progressionManager.IsStageUnlocked(null)); + } + + [Test] + public void IsStageUnlocked_EmptyStageId_ReturnsFalse() + { + Assert.IsFalse(_progressionManager.IsStageUnlocked(string.Empty)); + } + + [Test] + public void IsStageUnlocked_AfterUnlockStage_ReturnsTrue() + { + _playerPrefsKeys.Add("progress_unlock_my_stage"); + _progressionManager.UnlockStage("my_stage"); + + Assert.IsTrue(_progressionManager.IsStageUnlocked("my_stage")); + } + + // ------------------------------------------------------------------ + // GetBestStars + // ------------------------------------------------------------------ + + [Test] + public void GetBestStars_UnknownStageId_ReturnsZero() + { + Assert.AreEqual(0, _progressionManager.GetBestStars("nonexistent_stage")); + } + + [Test] + public void GetBestStars_NullStageId_ReturnsZero() + { + Assert.AreEqual(0, _progressionManager.GetBestStars(null)); + } + + [Test] + public void GetBestStars_EmptyStageId_ReturnsZero() + { + Assert.AreEqual(0, _progressionManager.GetBestStars(string.Empty)); + } + + [Test] + public void GetBestStars_AfterRecordCompletion_ReturnsSavedStars() + { + var stage = CreateStageDefinition("s_stars_test", "world1", 1); + SetupAllStages(new[] { stage }); + + _progressionManager.RecordStageCompletion("s_stars_test", 2); + + Assert.AreEqual(2, _progressionManager.GetBestStars("s_stars_test")); + } + + // ------------------------------------------------------------------ + // RecordStageCompletion + // ------------------------------------------------------------------ + + [Test] + public void RecordStageCompletion_NullStageId_DoesNotThrow() + { + Assert.DoesNotThrow(() => _progressionManager.RecordStageCompletion(null, 3), + "RecordStageCompletion with null stageId should log a warning (not throw)"); + } + + [Test] + public void RecordStageCompletion_EmptyStageId_DoesNotThrow() + { + Assert.DoesNotThrow(() => _progressionManager.RecordStageCompletion(string.Empty, 3), + "RecordStageCompletion with empty stageId should log a warning (not throw)"); + } + + [Test] + public void RecordStageCompletion_NullOrEmptyStageId_DoesNotUpdateBestStars() + { + _progressionManager.RecordStageCompletion(null, 3); + _progressionManager.RecordStageCompletion(string.Empty, 3); + + // Neither should have stored anything — GetBestStars returns 0 for unknown + Assert.AreEqual(0, _progressionManager.GetBestStars(null)); + Assert.AreEqual(0, _progressionManager.GetBestStars(string.Empty)); + } + + [Test] + public void RecordStageCompletion_FirstTime_SetsBestStars() + { + var stage = CreateStageDefinition("s_first", "world1", 1); + SetupAllStages(new[] { stage }); + + _progressionManager.RecordStageCompletion("s_first", 2); + + Assert.AreEqual(2, _progressionManager.GetBestStars("s_first")); + } + + [Test] + public void RecordStageCompletion_WithHigherStars_UpdatesBestStars() + { + var stage = CreateStageDefinition("s_higher", "world1", 1); + SetupAllStages(new[] { stage }); + + _progressionManager.RecordStageCompletion("s_higher", 1); + _progressionManager.RecordStageCompletion("s_higher", 3); + + Assert.AreEqual(3, _progressionManager.GetBestStars("s_higher"), + "Best stars should update when a higher star count is achieved"); + } + + [Test] + public void RecordStageCompletion_WithLowerStars_DoesNotDowngradeBestStars() + { + var stage = CreateStageDefinition("s_lower", "world1", 1); + SetupAllStages(new[] { stage }); + + _progressionManager.RecordStageCompletion("s_lower", 3); + _progressionManager.RecordStageCompletion("s_lower", 1); + + Assert.AreEqual(3, _progressionManager.GetBestStars("s_lower"), + "Best stars should NOT be downgraded when a lower star count is recorded"); + } + + [Test] + public void RecordStageCompletion_UnlocksNextStageInSameWorld() + { + var stage1 = CreateStageDefinition("w1_s1", "world1", 1); + var stage2 = CreateStageDefinition("w1_s2", "world1", 2); + SetupAllStages(new[] { stage1, stage2 }); + + // stage2 not yet unlocked + Assert.IsFalse(_progressionManager.IsStageUnlocked("w1_s2"), + "stage2 should not be unlocked before completing stage1"); + + _progressionManager.RecordStageCompletion("w1_s1", 1); + + Assert.IsTrue(_progressionManager.IsStageUnlocked("w1_s2"), + "Completing stage1 should unlock stage2 in the same world"); + } + + [Test] + public void RecordStageCompletion_WithNoNextStage_DoesNotThrow() + { + var stage1 = CreateStageDefinition("w1_only", "world1", 1); + SetupAllStages(new[] { stage1 }); + + Assert.DoesNotThrow(() => _progressionManager.RecordStageCompletion("w1_only", 2), + "RecordStageCompletion should not throw when there is no next stage"); + } + + [Test] + public void RecordStageCompletion_MarksCompletedStageAsUnlocked() + { + // Even if stage was not initially unlocked, completing it should mark it unlocked + var stage = CreateStageDefinition("s_mark_unlocked", "world1", 5); + SetupAllStages(new[] { stage }); + + _progressionManager.RecordStageCompletion("s_mark_unlocked", 1); + + Assert.IsTrue(_progressionManager.IsStageUnlocked("s_mark_unlocked"), + "RecordStageCompletion should mark the completed stage itself as unlocked"); + } + + // ------------------------------------------------------------------ + // UnlockStage + // ------------------------------------------------------------------ + + [Test] + public void UnlockStage_NullStageId_DoesNotThrow() + { + Assert.DoesNotThrow(() => _progressionManager.UnlockStage(null), + "UnlockStage with null stageId should log a warning (not throw)"); + } + + [Test] + public void UnlockStage_EmptyStageId_DoesNotThrow() + { + Assert.DoesNotThrow(() => _progressionManager.UnlockStage(string.Empty), + "UnlockStage with empty stageId should log a warning (not throw)"); + } + + [Test] + public void UnlockStage_NewStage_FiresOnStageUnlockedEvent() + { + string unlockedId = null; + _progressionManager.OnStageUnlocked += id => unlockedId = id; + + _progressionManager.UnlockStage("new_stage_event_test"); + _playerPrefsKeys.Add("progress_unlock_new_stage_event_test"); + _playerPrefsKeys.Add("progress_stars_new_stage_event_test"); + + Assert.AreEqual("new_stage_event_test", unlockedId, + "OnStageUnlocked should fire with the stageId when a new stage is unlocked"); + } + + [Test] + public void UnlockStage_AlreadyUnlocked_DoesNotFireEventAgain() + { + int eventCount = 0; + _progressionManager.OnStageUnlocked += _ => eventCount++; + _playerPrefsKeys.Add("progress_unlock_already_unlocked"); + _playerPrefsKeys.Add("progress_stars_already_unlocked"); + + _progressionManager.UnlockStage("already_unlocked"); + _progressionManager.UnlockStage("already_unlocked"); // second call + + Assert.AreEqual(1, eventCount, + "OnStageUnlocked should NOT fire again if the stage is already unlocked"); + } + + [Test] + public void UnlockStage_NullOrEmpty_DoesNotFireEvent() + { + int eventCount = 0; + _progressionManager.OnStageUnlocked += _ => eventCount++; + + _progressionManager.UnlockStage(null); + _progressionManager.UnlockStage(string.Empty); + + Assert.AreEqual(0, eventCount, + "OnStageUnlocked should not fire for null/empty stageId"); + } + + // ------------------------------------------------------------------ + // InitializeDefaults + // ------------------------------------------------------------------ + + [Test] + public void InitializeDefaults_UnlocksStageNumberOne_InEachWorld() + { + var w1s1 = CreateStageDefinition("w1_s1_init", "world1", 1); + var w1s2 = CreateStageDefinition("w1_s2_init", "world1", 2); + var w2s1 = CreateStageDefinition("w2_s1_init", "world2", 1); + SetupAllStages(new[] { w1s1, w1s2, w2s1 }); + + Assert.IsTrue(_progressionManager.IsStageUnlocked("w1_s1_init"), + "Stage number 1 of world1 should be unlocked by default"); + Assert.IsFalse(_progressionManager.IsStageUnlocked("w1_s2_init"), + "Stage number 2 of world1 should NOT be unlocked by default"); + Assert.IsTrue(_progressionManager.IsStageUnlocked("w2_s1_init"), + "Stage number 1 of world2 should be unlocked by default"); + } + + [Test] + public void InitializeDefaults_WithNullAllStages_DoesNotThrow() + { + // If _allStages is null, InitializeDefaults should do nothing (not throw) + SetPrivateField(_progressionManager, "_allStages", null); + + var initMethod = typeof(ProgressionManager).GetMethod( + "InitializeDefaults", + BindingFlags.NonPublic | BindingFlags.Instance); + + Assert.DoesNotThrow(() => initMethod.Invoke(_progressionManager, null), + "InitializeDefaults with null _allStages should not throw"); + } + + // ------------------------------------------------------------------ + // FindNextStage (via RecordStageCompletion) + // ------------------------------------------------------------------ + + [Test] + public void FindNextStage_ReturnsNull_WhenNoNextStageExists() + { + // Only one stage in the world; completing it should not unlock anything new + var onlyStage = CreateStageDefinition("w_solo", "solo_world", 1); + SetupAllStages(new[] { onlyStage }); + + int eventCount = 0; + _progressionManager.OnStageUnlocked += _ => eventCount++; + + // OnStageUnlocked fires when the solo stage itself gets marked unlocked + // (RecordStageCompletion always calls UnlockStage on the completed stage). + // We specifically want to verify no NEXT stage gets unlocked. + _progressionManager.RecordStageCompletion("w_solo", 1); + + // Only the completed stage itself should have been "unlocked" via RecordStageCompletion. + // Since w_solo was already unlocked by InitializeDefaults (stageNumber==1), + // UnlockStage would have found it already-unlocked and not fired the event. + // Either way, no ADDITIONAL stage unlock event beyond the solo stage should fire. + Assert.IsFalse(_progressionManager.IsStageUnlocked("w_solo_nonexistent"), + "There should be no next-stage unlock for a solo world"); + } + + [Test] + public void FindNextStage_DoesNotUnlockStageFromDifferentWorld() + { + var w1s1 = CreateStageDefinition("w1_cross", "worldA", 1); + var w2s2 = CreateStageDefinition("w2_cross", "worldB", 2); // stageNumber 2 in worldB + + SetupAllStages(new[] { w1s1, w2s2 }); + + _progressionManager.RecordStageCompletion("w1_cross", 1); + + // worldB stage2 should NOT be unlocked — different world + Assert.IsFalse(_progressionManager.IsStageUnlocked("w2_cross"), + "Completing a stage in worldA should not unlock stages in worldB"); + } + + // ------------------------------------------------------------------ + // HandleStageCompleted (private, tested via StageManager.OnStageCompleted) + // ------------------------------------------------------------------ + + [Test] + public void HandleStageCompleted_WhenBreakdownNotPassed_DoesNotRecordProgression() + { + var stage = CreateStageDefinition("s_fail_test", "world1", 1); + SetupAllStages(new[] { stage }); + + // Manually set CurrentStage on StageManager via LoadStage + // We need a GameManager that will handle the board reset — already wired + var stage2 = ScriptableObject.CreateInstance(); + _createdObjects.Add(stage2); + SetPrivateField(stage2, "_stageId", "s_fail_test"); + SetPrivateField(stage2, "_displayName", "s_fail_test"); + SetPrivateField(stage2, "_worldId", "world1"); + SetPrivateField(stage2, "_stageNumber", 1); + SetPrivateField(stage2, "_targetArea", 4); + + // Load stage so CurrentStage is set + _stageManager.LoadStage(stage2); + + int initialStars = _progressionManager.GetBestStars("s_fail_test"); + + // Manually raise OnStageCompleted with a failing breakdown + var failBreakdown = CreateFailingBreakdown(); + + // Fire the event via StageManager's OnStageCompleted + // ProgressionManager is subscribed via OnEnable + RaiseStageCompleted(failBreakdown); + + // Stars should not have changed (still 0) + Assert.AreEqual(initialStars, _progressionManager.GetBestStars("s_fail_test"), + "HandleStageCompleted should NOT record progression when breakdown.Passed is false"); + } + + [Test] + public void HandleStageCompleted_WhenBreakdownPassed_RecordsProgression() + { + var stage = CreateStageDefinition("s_pass_test", "world1", 1); + SetupAllStages(new[] { stage }); + + // Load a matching stage into StageManager + var stageSo = ScriptableObject.CreateInstance(); + _createdObjects.Add(stageSo); + SetPrivateField(stageSo, "_stageId", "s_pass_test"); + SetPrivateField(stageSo, "_displayName", "s_pass_test"); + SetPrivateField(stageSo, "_worldId", "world1"); + SetPrivateField(stageSo, "_stageNumber", 1); + SetPrivateField(stageSo, "_targetArea", 4); + + _stageManager.LoadStage(stageSo); + + // Subscribe ProgressionManager to StageManager event (OnEnable wires it) + // Since both components are active, OnEnable already fired. + // Re-subscribe manually in case ordering was off: + var onEnableMethod = typeof(ProgressionManager).GetMethod( + "OnEnable", + BindingFlags.NonPublic | BindingFlags.Instance); + onEnableMethod.Invoke(_progressionManager, null); + + var passBreakdown = CreatePassingBreakdown(2); + RaiseStageCompleted(passBreakdown); + + Assert.AreEqual(2, _progressionManager.GetBestStars("s_pass_test"), + "HandleStageCompleted should record progression when breakdown.Passed is true"); + } + + // ------------------------------------------------------------------ + // SaveProgress / LoadProgress / ClearProgress (smoke tests) + // ------------------------------------------------------------------ + + [Test] + public void SaveProgress_WithNullAllStages_DoesNotThrow() + { + SetPrivateField(_progressionManager, "_allStages", null); + + Assert.DoesNotThrow(() => _progressionManager.SaveProgress(), + "SaveProgress with null _allStages should not throw"); + } + + [Test] + public void LoadProgress_WithNullAllStages_DoesNotThrow() + { + SetPrivateField(_progressionManager, "_allStages", null); + + Assert.DoesNotThrow(() => _progressionManager.LoadProgress(), + "LoadProgress with null _allStages should not throw"); + } + + [Test] + public void ClearProgress_WithNullAllStages_DoesNotThrow() + { + SetPrivateField(_progressionManager, "_allStages", null); + + Assert.DoesNotThrow(() => _progressionManager.ClearProgress(), + "ClearProgress with null _allStages should not throw"); + } + + [Test] + public void SaveAndLoadProgress_RoundtripsStarData() + { + var stage = CreateStageDefinition("s_roundtrip", "world1", 1); + SetupAllStages(new[] { stage }); + + _progressionManager.RecordStageCompletion("s_roundtrip", 3); + _progressionManager.SaveProgress(); + + // Reset internal state then reload + var bestStarsField = typeof(ProgressionManager).GetField( + "_bestStars", + BindingFlags.NonPublic | BindingFlags.Instance); + ((Dictionary)bestStarsField.GetValue(_progressionManager)).Clear(); + + _progressionManager.LoadProgress(); + + Assert.AreEqual(3, _progressionManager.GetBestStars("s_roundtrip"), + "Best stars should survive a Save + Load cycle"); + } + + [Test] + public void ClearProgress_RemovesUnlockDataFromPlayerPrefs() + { + var stage = CreateStageDefinition("s_clear", "world1", 1); + SetupAllStages(new[] { stage }); + + _progressionManager.RecordStageCompletion("s_clear", 2); + _progressionManager.SaveProgress(); + + _progressionManager.ClearProgress(); + + // After clear, loading should not restore the old unlock state + var unlockedField = typeof(ProgressionManager).GetField( + "_unlockedStages", + BindingFlags.NonPublic | BindingFlags.Instance); + ((Dictionary)unlockedField.GetValue(_progressionManager)).Clear(); + + _progressionManager.LoadProgress(); + + // The key was deleted, so IsStageUnlocked should return false (not found in dict) + Assert.IsFalse(_progressionManager.IsStageUnlocked("s_clear"), + "After ClearProgress, unlock state should not be restored by LoadProgress"); + } + + // ------------------------------------------------------------------ + // Private helper: raise OnStageCompleted on StageManager + // ------------------------------------------------------------------ + + private void RaiseStageCompleted(ScoreBreakdown breakdown) + { + // Use reflection to raise the OnStageCompleted event on StageManager + var eventField = typeof(StageManager).GetField( + "OnStageCompleted", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public); + + // C# events backed by multicast delegates: get the backing field + var delegateVal = eventField?.GetValue(_stageManager) as System.MulticastDelegate; + if (delegateVal != null) + { + delegateVal.DynamicInvoke(breakdown); + } + } + } +} diff --git a/Assets/10_Scripts/30_Tests/70_Managers/ProgressionManagerTests.cs.meta b/Assets/10_Scripts/30_Tests/70_Managers/ProgressionManagerTests.cs.meta new file mode 100644 index 0000000..c3d7ddc --- /dev/null +++ b/Assets/10_Scripts/30_Tests/70_Managers/ProgressionManagerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 011da3d893b252d46812df90a195e012 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/30_Tests/70_Managers/StageManagerTests.cs b/Assets/10_Scripts/30_Tests/70_Managers/StageManagerTests.cs new file mode 100644 index 0000000..71d94b9 --- /dev/null +++ b/Assets/10_Scripts/30_Tests/70_Managers/StageManagerTests.cs @@ -0,0 +1,415 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using NUnit.Framework; +using CircuitCraft.Core; +using CircuitCraft.Data; +using CircuitCraft.Managers; +using UnityEngine; + +namespace CircuitCraft.Tests.Managers +{ + /// + /// Characterization tests for StageManager — captures ALL observable public-surface behaviors + /// as a safety net before refactoring. Tests use only the synchronous API surface and + /// event-firing verification; async UniTask flows are not tested here. + /// + [TestFixture] + public class StageManagerTests + { + // Track all created Unity objects for cleanup + private readonly List _createdObjects = new List(); + + private GameObject _stageManagerGo; + private GameObject _gameManagerGo; + private GameObject _simulationManagerGo; + + private StageManager _stageManager; + private GameManager _gameManager; + private SimulationManager _simulationManager; + + [SetUp] + public void SetUp() + { + // Create GameObjects and add components + _gameManagerGo = new GameObject("TestGameManager"); + _createdObjects.Add(_gameManagerGo); + + _simulationManagerGo = new GameObject("TestSimulationManager"); + _createdObjects.Add(_simulationManagerGo); + + _stageManagerGo = new GameObject("TestStageManager"); + _createdObjects.Add(_stageManagerGo); + + // Add components (Awake fires during AddComponent) + _simulationManager = _simulationManagerGo.AddComponent(); + _gameManager = _gameManagerGo.AddComponent(); + _stageManager = _stageManagerGo.AddComponent(); + + // Wire serialized dependencies via reflection + SetPrivateField(_stageManager, "_gameManager", _gameManager); + SetPrivateField(_stageManager, "_simulationManager", _simulationManager); + } + + [TearDown] + public void TearDown() + { + foreach (var obj in _createdObjects) + { + if (obj != null) + UnityEngine.Object.DestroyImmediate(obj); + } + _createdObjects.Clear(); + } + + // ------------------------------------------------------------------ + // Helper Methods + // ------------------------------------------------------------------ + + private static void SetPrivateField(object target, string fieldName, T value) + { + var field = target.GetType().GetField( + fieldName, + BindingFlags.NonPublic | BindingFlags.Instance); + Assert.IsNotNull(field, $"Field '{fieldName}' not found on {target.GetType().Name}"); + field.SetValue(target, value); + } + + private StageDefinition CreateStageDefinition( + string stageId, + string displayName, + string worldId, + int stageNumber, + int targetArea, + float budgetLimit = 0f) + { + var stage = ScriptableObject.CreateInstance(); + _createdObjects.Add(stage); + SetPrivateField(stage, "_stageId", stageId); + SetPrivateField(stage, "_displayName", displayName); + SetPrivateField(stage, "_worldId", worldId); + SetPrivateField(stage, "_stageNumber", stageNumber); + SetPrivateField(stage, "_targetArea", targetArea); + SetPrivateField(stage, "_budgetLimit", budgetLimit); + return stage; + } + + private ComponentDefinition CreateComponentDefinition(string id, ComponentKind kind) + { + var def = ScriptableObject.CreateInstance(); + _createdObjects.Add(def); + SetPrivateField(def, "_id", id); + SetPrivateField(def, "_displayName", id); + SetPrivateField(def, "_kind", kind); + // Explicitly set empty pins array so PinInstanceFactory uses standard pins + SetPrivateField(def, "_pins", new PinDefinition[0]); + return def; + } + + // ------------------------------------------------------------------ + // CurrentStage property + // ------------------------------------------------------------------ + + [Test] + public void CurrentStage_BeforeAnyLoad_ReturnsNull() + { + Assert.IsNull(_stageManager.CurrentStage); + } + + [Test] + public void CurrentStage_AfterLoadStage_ReturnsLoadedStage() + { + var stage = CreateStageDefinition("s1", "Stage 1", "world1", 1, 9); + + _stageManager.LoadStage(stage); + + Assert.AreSame(stage, _stageManager.CurrentStage); + } + + // ------------------------------------------------------------------ + // LoadStage — null guard + // ------------------------------------------------------------------ + + [Test] + public void LoadStage_NullStage_ThrowsArgumentNullException() + { + Assert.Throws(() => _stageManager.LoadStage(null)); + } + + // ------------------------------------------------------------------ + // LoadStage — board reset dimensions + // ------------------------------------------------------------------ + + [Test] + public void LoadStage_ResetsBoard_ToDerivedSquareSize() + { + // TargetArea = 9 -> side = ceil(sqrt(9)) = 3 + var stage = CreateStageDefinition("s1", "Stage 1", "world1", 1, 9); + + _stageManager.LoadStage(stage); + + var bounds = _gameManager.BoardState.SuggestedBounds; + Assert.AreEqual(3, bounds.Width, "Board width should equal ceil(sqrt(TargetArea))"); + Assert.AreEqual(3, bounds.Height, "Board height should equal ceil(sqrt(TargetArea))"); + } + + [Test] + public void LoadStage_ResetsBoard_RoundsUpForNonPerfectSquare() + { + // TargetArea = 10 -> side = ceil(sqrt(10)) = 4 (sqrt(10) ~ 3.16) + var stage = CreateStageDefinition("s-nps", "Non-perfect", "world1", 1, 10); + + _stageManager.LoadStage(stage); + + var bounds = _gameManager.BoardState.SuggestedBounds; + Assert.AreEqual(4, bounds.Width, "Board width should be ceiling of sqrt(10)"); + Assert.AreEqual(4, bounds.Height, "Board height should be ceiling of sqrt(10)"); + } + + // ------------------------------------------------------------------ + // LoadStage — event firing + // ------------------------------------------------------------------ + + [Test] + public void LoadStage_FiresOnStageLoadedEvent() + { + var stage = CreateStageDefinition("s1", "Stage 1", "world1", 1, 4); + bool eventFired = false; + _stageManager.OnStageLoaded += () => eventFired = true; + + _stageManager.LoadStage(stage); + + Assert.IsTrue(eventFired, "OnStageLoaded should fire after LoadStage"); + } + + [Test] + public void LoadStage_OnStageLoaded_FiredBeforePlacingFixedComponents() + { + // Event should fire before components are placed, but board should still reflect reset + var stage = CreateStageDefinition("s1", "Stage 1", "world1", 1, 4); + int componentCountAtEvent = -1; + _stageManager.OnStageLoaded += () => + { + componentCountAtEvent = _gameManager.BoardState.Components.Count; + }; + + _stageManager.LoadStage(stage); + + // No fixed placements on this stage — board should be empty at event time + Assert.AreEqual(0, componentCountAtEvent, "Board should be empty when OnStageLoaded fires (before fixed placements)"); + } + + // ------------------------------------------------------------------ + // LoadStage — fixed placements + // ------------------------------------------------------------------ + + [Test] + public void LoadStage_EmptyFixedPlacements_DoesNotThrow() + { + var stage = CreateStageDefinition("s-empty", "Empty", "world1", 1, 4); + SetPrivateField(stage, "_fixedPlacements", new FixedPlacement[0]); + + Assert.DoesNotThrow(() => _stageManager.LoadStage(stage)); + Assert.AreEqual(0, _gameManager.BoardState.Components.Count); + } + + [Test] + public void LoadStage_PlacesFixedComponentsOnBoard() + { + var stage = CreateStageDefinition("s-fixed", "Fixed", "world1", 1, 16); + + var resistorDef = CreateComponentDefinition("test_resistor", ComponentKind.Resistor); + var placements = new FixedPlacement[] + { + new FixedPlacement + { + component = resistorDef, + position = new Vector2Int(0, 0), + rotation = 0, + overrideCustomValue = false, + customValue = 0f + } + }; + SetPrivateField(stage, "_fixedPlacements", placements); + + _stageManager.LoadStage(stage); + + Assert.AreEqual(1, _gameManager.BoardState.Components.Count, + "One fixed component should be placed on board"); + } + + [Test] + public void LoadStage_NullComponentInFixedPlacements_SkipsWithoutCrashing() + { + var stage = CreateStageDefinition("s-null-fp", "NullFP", "world1", 1, 16); + + // FixedPlacement is a struct; component field defaults to null + var placements = new FixedPlacement[] + { + new FixedPlacement { component = null, position = Vector2Int.zero } + }; + SetPrivateField(stage, "_fixedPlacements", placements); + + Assert.DoesNotThrow(() => _stageManager.LoadStage(stage), + "Null component in FixedPlacements should be skipped without throwing"); + Assert.AreEqual(0, _gameManager.BoardState.Components.Count, + "No components should be placed when fixed placement has null component"); + } + + [Test] + public void LoadStage_FixedComponent_IsMarkedAsFixed() + { + var stage = CreateStageDefinition("s-fixed-flag", "FixedFlag", "world1", 1, 16); + + var resistorDef = CreateComponentDefinition("test_resistor_fixed", ComponentKind.Resistor); + var placements = new FixedPlacement[] + { + new FixedPlacement + { + component = resistorDef, + position = new Vector2Int(0, 0), + rotation = 0, + overrideCustomValue = false, + customValue = 0f + } + }; + SetPrivateField(stage, "_fixedPlacements", placements); + + _stageManager.LoadStage(stage); + + var placed = _gameManager.BoardState.Components[0]; + Assert.IsTrue(placed.IsFixed, "Fixed placement component should have IsFixed=true"); + } + + // ------------------------------------------------------------------ + // LoadStage — Probe component creates OUT net + // ------------------------------------------------------------------ + + [Test] + public void LoadStage_ProbeFixedComponent_CreatesOutNet() + { + var stage = CreateStageDefinition("s-probe", "Probe", "world1", 1, 16); + + var probeDef = CreateComponentDefinition("test_probe", ComponentKind.Probe); + var placements = new FixedPlacement[] + { + new FixedPlacement + { + component = probeDef, + position = new Vector2Int(0, 0), + rotation = 0, + overrideCustomValue = false, + customValue = 0f + } + }; + SetPrivateField(stage, "_fixedPlacements", placements); + + _stageManager.LoadStage(stage); + + var boardState = _gameManager.BoardState; + bool hasOutNet = false; + foreach (var net in boardState.Nets) + { + if (net.NetName == "OUT") + { + hasOutNet = true; + break; + } + } + + Assert.IsTrue(hasOutNet, "LoadStage should create an 'OUT' net for fixed Probe components"); + } + + [Test] + public void LoadStage_ProbeFixedComponent_ConnectsPinToOutNet() + { + var stage = CreateStageDefinition("s-probe-conn", "ProbeConn", "world1", 1, 16); + + var probeDef = CreateComponentDefinition("test_probe_conn", ComponentKind.Probe); + var placements = new FixedPlacement[] + { + new FixedPlacement + { + component = probeDef, + position = new Vector2Int(0, 0), + rotation = 0, + overrideCustomValue = false, + customValue = 0f + } + }; + SetPrivateField(stage, "_fixedPlacements", placements); + + _stageManager.LoadStage(stage); + + var boardState = _gameManager.BoardState; + Net outNet = null; + foreach (var net in boardState.Nets) + { + if (net.NetName == "OUT") + { + outNet = net; + break; + } + } + + Assert.IsNotNull(outNet, "OUT net must exist"); + Assert.Greater(outNet.ConnectedPins.Count, 0, + "OUT net should have at least 1 connected pin (probe pin 0)"); + } + + // ------------------------------------------------------------------ + // RunSimulationAndEvaluate — guard checks + // ------------------------------------------------------------------ + + [Test] + public void RunSimulationAndEvaluate_WithNoStageLoaded_LogsWarning() + { + // Should not throw; should log a warning. We verify it doesn't crash. + Assert.DoesNotThrow(() => _stageManager.RunSimulationAndEvaluate(), + "RunSimulationAndEvaluate should log a warning (not throw) when no stage is loaded"); + } + + // ------------------------------------------------------------------ + // LoadStage — successive loads replace CurrentStage + // ------------------------------------------------------------------ + + [Test] + public void LoadStage_CalledTwice_CurrentStageIsSecondStage() + { + var stage1 = CreateStageDefinition("s1", "Stage 1", "world1", 1, 4); + var stage2 = CreateStageDefinition("s2", "Stage 2", "world1", 2, 9); + + _stageManager.LoadStage(stage1); + _stageManager.LoadStage(stage2); + + Assert.AreSame(stage2, _stageManager.CurrentStage, + "CurrentStage should reflect the most recently loaded stage"); + } + + [Test] + public void LoadStage_CalledTwice_BoardIsResetEachTime() + { + var stage1 = CreateStageDefinition("s1", "Stage 1", "world1", 1, 4); + var stage2 = CreateStageDefinition("s2", "Stage 2", "world1", 2, 16); + + _stageManager.LoadStage(stage1); + // Place something manually after stage1 load + _gameManager.BoardState.PlaceComponent( + "manual", + new GridPosition(0, 0), + 0, + new List { new PinInstance(0, "p0", new GridPosition(0, 0)) }); + + _stageManager.LoadStage(stage2); + + // Board should be cleared on second load (manual component gone) + Assert.AreEqual(0, _gameManager.BoardState.Components.Count, + "Second LoadStage should reset board, clearing manually placed components"); + } + + // ------------------------------------------------------------------ + // Events — OnStageCompleted is NOT tested here (requires full async sim) + // OnDRCCompleted is NOT tested here (requires async flow) + // ------------------------------------------------------------------ + } +} diff --git a/Assets/10_Scripts/30_Tests/70_Managers/StageManagerTests.cs.meta b/Assets/10_Scripts/30_Tests/70_Managers/StageManagerTests.cs.meta new file mode 100644 index 0000000..2abc10a --- /dev/null +++ b/Assets/10_Scripts/30_Tests/70_Managers/StageManagerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3d548d554d0080f41b9832b62f73e7c5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/10_Scripts/20_Tests/CircuitCraft.Tests.asmdef b/Assets/10_Scripts/30_Tests/CircuitCraft.Tests.asmdef similarity index 66% rename from Assets/10_Scripts/20_Tests/CircuitCraft.Tests.asmdef rename to Assets/10_Scripts/30_Tests/CircuitCraft.Tests.asmdef index bf0db58..59a3300 100644 --- a/Assets/10_Scripts/20_Tests/CircuitCraft.Tests.asmdef +++ b/Assets/10_Scripts/30_Tests/CircuitCraft.Tests.asmdef @@ -5,6 +5,13 @@ "CircuitCraft.Core", "CircuitCraft.Data", "CircuitCraft.Systems", + "CircuitCraft.Commands", + "CircuitCraft.Components", + "CircuitCraft.Managers", + "CircuitCraft.Views", + "CircuitCraft.Controllers", + "CircuitCraft.UI", + "CircuitCraft.Utils", "UniTask" ], "includePlatforms": [ @@ -14,7 +21,8 @@ "allowUnsafeCode": false, "overrideReferences": true, "precompiledReferences": [ - "nunit.framework.dll" + "nunit.framework.dll", + "SpiceSharp.dll" ], "autoReferenced": false, "defineConstraints": [ diff --git a/Assets/10_Scripts/20_Tests/CircuitCraft.Tests.asmdef.meta b/Assets/10_Scripts/30_Tests/CircuitCraft.Tests.asmdef.meta similarity index 100% rename from Assets/10_Scripts/20_Tests/CircuitCraft.Tests.asmdef.meta rename to Assets/10_Scripts/30_Tests/CircuitCraft.Tests.asmdef.meta diff --git a/Assets/20_Prefabs/Components.meta b/Assets/20_Prefabs/50_Components.meta similarity index 100% rename from Assets/20_Prefabs/Components.meta rename to Assets/20_Prefabs/50_Components.meta diff --git a/Assets/10_Scripts/10_Runtime/60_Utils/.gitkeep b/Assets/20_Prefabs/50_Components/.gitkeep similarity index 100% rename from Assets/10_Scripts/10_Runtime/60_Utils/.gitkeep rename to Assets/20_Prefabs/50_Components/.gitkeep diff --git a/Assets/20_Prefabs/Components/ComponentView.prefab b/Assets/20_Prefabs/50_Components/ComponentView.prefab similarity index 97% rename from Assets/20_Prefabs/Components/ComponentView.prefab rename to Assets/20_Prefabs/50_Components/ComponentView.prefab index 71c572b..b9731f7 100644 --- a/Assets/20_Prefabs/Components/ComponentView.prefab +++ b/Assets/20_Prefabs/50_Components/ComponentView.prefab @@ -27,7 +27,7 @@ Transform: m_GameObject: {fileID: 5130435250061090791} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalPosition: {x: 0, y: -0.65, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] @@ -73,7 +73,7 @@ MeshRenderer: m_LightmapParameters: {fileID: 0} m_SortingLayerID: 0 m_SortingLayer: 0 - m_SortingOrder: 0 + m_SortingOrder: 5 m_AdditionalVertexStreams: {fileID: 0} --- !u!102 &4734578627833471083 TextMesh: @@ -85,12 +85,12 @@ TextMesh: m_GameObject: {fileID: 5130435250061090791} m_Text: m_OffsetZ: 0 - m_CharacterSize: 1 + m_CharacterSize: 0.12 m_LineSpacing: 1 - m_Anchor: 0 - m_Alignment: 0 + m_Anchor: 4 + m_Alignment: 1 m_TabSize: 4 - m_FontSize: 0 + m_FontSize: 24 m_FontStyle: 0 m_RichText: 1 m_Font: {fileID: 0} diff --git a/Assets/20_Prefabs/Components/ComponentView.prefab.meta b/Assets/20_Prefabs/50_Components/ComponentView.prefab.meta similarity index 100% rename from Assets/20_Prefabs/Components/ComponentView.prefab.meta rename to Assets/20_Prefabs/50_Components/ComponentView.prefab.meta diff --git a/Assets/20_UI.meta b/Assets/20_UI.meta deleted file mode 100644 index 808df4b..0000000 --- a/Assets/20_UI.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: e33e172388aeb634e98d99de2501a5f4 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/20_UI/Scripts.meta b/Assets/20_UI/Scripts.meta deleted file mode 100644 index 181d751..0000000 --- a/Assets/20_UI/Scripts.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 875fa44f86d0f4049ae4c1deac9c87b4 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/20_UI/Scripts/MainMenuController.cs b/Assets/20_UI/Scripts/MainMenuController.cs deleted file mode 100644 index 02aeee4..0000000 --- a/Assets/20_UI/Scripts/MainMenuController.cs +++ /dev/null @@ -1,59 +0,0 @@ -using UnityEngine; -using UnityEngine.UIElements; -using UnityEngine.SceneManagement; - -namespace CircuitCraft.UI -{ - public class MainMenuController : MonoBehaviour - { - [SerializeField] private UIDocument uiDocument; - - private Button _playButton; - private Button _settingsButton; - private Button _quitButton; - - private void OnEnable() - { - if (uiDocument == null) - uiDocument = GetComponent(); - - if (uiDocument == null) return; - - var root = uiDocument.rootVisualElement; - if (root == null) return; - - _playButton = root.Q