diff --git a/README.md b/README.md index 94e8b3a0..4fa726ce 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,13 @@ Further reading: - [Technical Debt](DevelopmentNotes/TechnicalDebt.md) document in the DevelopmentNotes folder. +## Note on LLM Use + +This package is LLM free with exception of some unit tests. + +If you want to contribute to this package, please do not use LLMs to write code. Only exception is +unit tests, which require to be reviewed thoroughly by a human. + ## Author - [Stefan Urbanek](mailto:stefan.urbanek@gmail.com) diff --git a/Sources/PoieticCore/Constraints/ConstraintChecker.swift b/Sources/PoieticCore/Constraints/ConstraintChecker.swift index 3bc1bf84..05b0670a 100644 --- a/Sources/PoieticCore/Constraints/ConstraintChecker.swift +++ b/Sources/PoieticCore/Constraints/ConstraintChecker.swift @@ -13,7 +13,8 @@ /// owning a frame. /// public struct ConstraintChecker { - // NOTE: This object could have been a function, but I like the steps to be separated. + // IMPORTANT: Maintain validate(...) and diagnose(...) function pairs in sync. + // ========= /// Metamodel associated with the constraint checker. Frames and objects /// will be validated using the constraints and object types defined @@ -29,8 +30,28 @@ public struct ConstraintChecker { public init(_ metamodel: Metamodel) { self.metamodel = metamodel } - - /// Checks object's conformance to a trait. + public func validate(_ object: some ObjectProtocol, conformsTo type: ObjectType) throws (ObjectTypeError) { + if object.structure.type != type.structuralType { + throw .structureMismatch(object.type.structuralType) + } + + for trait in type.traits { + try validate(object, conformsTo: trait) + } + + } + public func diagnose(_ object: some ObjectProtocol, conformsTo type: ObjectType) -> [ObjectTypeError] { + var errors:[ObjectTypeError] = [] + if object.structure.type != type.structuralType { + errors.append(.structureMismatch(object.type.structuralType)) + } + + for trait in type.traits { + errors += diagnose(object, conformsTo: trait) + } + return errors + } + /// Validate object's conformance to a trait. /// /// The object conforms to a trait if the following is true: /// @@ -38,40 +59,62 @@ public struct ConstraintChecker { /// - All attributes from the trait that are present in the object /// must be convertible to the type of the corresponding trait attribute. /// - /// For each non-met requirement an error is included in the result. - /// /// - Parameters: /// - `trait`: Trait to be used for checking /// - /// - Throws: ``ObjectTypeErrorCollection`` when the object does not conform - /// to the trait. + /// - Throws: ``ObjectTypeError`` for first violation detected. + /// - SeeAlso: ``diagnose(_:conformsTo:)`` for collecting all issues with an object. /// - public func check(_ snapshot: ObjectSnapshot, conformsTo trait: Trait) throws (ObjectTypeErrorCollection) { - var errors:[ObjectTypeError] = [] - + public func validate(_ object: some ObjectProtocol, conformsTo trait: Trait) throws (ObjectTypeError) { for attr in trait.attributes { - if let value = snapshot[attr.name] { - + if let value = object[attr.name] { // For type validation to work correctly we must make sure that // the types are persisted and restored. // - if !value.isRepresentable(as: attr.type) { - let error = ObjectTypeError.typeMismatch(attr, value.valueType) - errors.append(error) + guard value.isRepresentable(as: attr.type) else { + throw .typeMismatch(attr, value.valueType) } } else if attr.optional { continue } else { - let error = ObjectTypeError.missingTraitAttribute(attr, trait.name) - errors.append(error) + throw .missingTraitAttribute(attr, trait.name) } } + } + + /// Validate object's conformance to a trait and collect all issues. + /// + /// The object conforms to a trait if the following is true: + /// + /// - Object has values for all traits required attributes + /// - All attributes from the trait that are present in the object + /// must be convertible to the type of the corresponding trait attribute. + /// + /// - Parameters: + /// - `trait`: Trait to be used for checking + /// + /// - Returns: A collection of detected issues. + /// - SeeAlso: ``validate(_:conformsTo:)`` for failing fast on first error. + /// + public func diagnose(_ object: some ObjectProtocol, conformsTo trait: Trait) -> [ObjectTypeError] { + var errors:[ObjectTypeError] = [] - guard errors.isEmpty else { - throw ObjectTypeErrorCollection(errors) + for attr in trait.attributes { + if let value = object[attr.name] { + if !value.isRepresentable(as: attr.type) { + errors.append(.typeMismatch(attr, value.valueType)) + } + } + else if attr.optional { + continue + } + else { + errors.append(.missingTraitAttribute(attr, trait.name)) + } } + return errors } /// Check a frame for constraints violations and object type conformance. @@ -89,43 +132,35 @@ public struct ConstraintChecker { /// /// - SeeAlso: ``Design/accept(_:appendHistory:)``, ``ObjectSnapshotProtocol/check(conformsTo:)`` /// - public func check(_ frame: some Frame) throws (FrameValidationError) { - var errors: [ObjectID: [ObjectTypeError]] = [:] + public func diagnose(_ frame: some Frame) -> FrameValidationResult { + // IMPORTANT: Keep in sync with validate(...) version of this method + var objectErrors: [ObjectID: [ObjectTypeError]] = [:] var edgeViolations: [ObjectID: [EdgeRuleViolation]] = [:] + // Check types - // ------------------------------------------------------------ - for snapshot in frame.snapshots { - guard let type = metamodel.objectType(name: snapshot.type.name) else { - let error = ObjectTypeError.unknownType(snapshot.type.name) - errors[snapshot.objectID, default: []].append(error) - continue - } - if snapshot.type.structuralType != snapshot.structure.type { - let error = ObjectTypeError.structureMismatch(snapshot.type.structuralType) - errors[snapshot.objectID, default: []].append(error) + // + for object in frame.snapshots { + guard metamodel.hasType(object.type) else { + objectErrors[object.objectID, default: []].append(.unknownType(object.type.name)) + continue // Nothing to validate, the object is not known to metamodel } - - for trait in type.traits { - do { - try check(snapshot, conformsTo: trait) - } - catch { - errors[snapshot.objectID, default: []].append(contentsOf: error.errors) - } + let errors = diagnose(object, conformsTo: object.type) + if !errors.isEmpty { + objectErrors[object.objectID, default: []] += errors } - if let edge = EdgeObject(snapshot, in: frame) { + if let edge = EdgeObject(object, in: frame) { do { try validate(edge: edge, in: frame) } catch { - edgeViolations[snapshot.objectID, default: []].append(error) + edgeViolations[object.objectID, default: []].append(error) } } } // Check constraints - // ------------------------------------------------------------ + // var violations: [ConstraintViolation] = [] for constraint in metamodel.constraints { let violators = constraint.check(frame) @@ -135,17 +170,46 @@ public struct ConstraintChecker { } } - // Throw an error if there are any violations or errors - - guard violations.isEmpty - && errors.isEmpty - && edgeViolations.isEmpty else { - throw FrameValidationError(violations: violations, - objectErrors: errors, - edgeRuleViolations: edgeViolations) - } + return FrameValidationResult( + violations: violations, + objectErrors: objectErrors, + edgeRuleViolations: edgeViolations + ) } + public func validate(_ frame: some Frame) throws (FrameValidationError) { + // IMPORTANT: Keep in sync with diagnose(...) version of this method + + for object in frame.snapshots { + guard metamodel.hasType(object.type) else { + throw .objectTypeError(object.objectID, .unknownType(object.type.name)) + } + do { + try validate(object, conformsTo: object.type) + } + catch { + throw .objectTypeError(object.objectID, error) + } + + if let edge = EdgeObject(object, in: frame) { + do { + try validate(edge: edge, in: frame) + } + catch { + throw .edgeRuleViolation(edge.key, error) + } + } + } + + for constraint in metamodel.constraints { + let violators = constraint.check(frame) + guard violators.isEmpty else { + throw .constraintViolation(ConstraintViolation(constraint: constraint, + objects: violators)) + } + } + } + /// Validates the edge whether it matches the metamodel's edge rules. /// /// The validation process is as follows: @@ -162,6 +226,40 @@ public struct ConstraintChecker { /// - SeeAlso: ``Metamodel/edgeRules``, ``EdgeRule`` /// - Throws: ``EdgeRuleViolation`` + public func validate(edgeType: ObjectType, origin: ObjectID, target: ObjectID, in frame: some Frame) throws (EdgeRuleViolation) { + // NOTE: Changes in this function should be synced with func canConnect(...) + let originObject = frame[origin]! + let targetObject = frame[target]! + + let typeRules = metamodel.edgeRules.filter { edgeType === $0.type } + if typeRules.count == 0 { + throw .edgeNotAllowed + } + guard let matchingRule = typeRules.first(where: { rule in + rule.match(edgeType, origin: originObject, target: targetObject, in: frame) + }) + else { + throw .noRuleSatisfied + } + + let outgoingCount = frame.outgoing(origin).count { $0.object.type === matchingRule.type } + switch matchingRule.outgoing { + case .many: break + case .one: + if outgoingCount != 1 { + throw .cardinalityViolation(matchingRule, .outgoing) + } + } + + let incomingCount = frame.incoming(target).count { $0.object.type === matchingRule.type } + switch matchingRule.incoming { + case .many: break + case .one: + if incomingCount != 1 { + throw .cardinalityViolation(matchingRule, .incoming) + } + } + } public func validate(edge: EdgeObject, in frame: some Frame) throws (EdgeRuleViolation) { // NOTE: Changes in this function should be synced with func canConnect(...) @@ -241,3 +339,16 @@ public struct ConstraintChecker { } +// TODO: A sketch, not yet used +public struct MetamodelValidationMode: OptionSet, Sendable { + public let rawValue: Int8 + public init(rawValue: RawValue) { + self.rawValue = rawValue + } + public static let allowUnknownTypes = MetamodelValidationMode(rawValue: 1 << 0) + public static let allowUnknownEdges = MetamodelValidationMode(rawValue: 1 << 1) + public static let ignoreConstraints = MetamodelValidationMode(rawValue: 1 << 2) + + public static let strict: MetamodelValidationMode = [] + public static let permissive: MetamodelValidationMode = [.allowUnknownTypes, .allowUnknownEdges, .ignoreConstraints] +} diff --git a/Sources/PoieticCore/Constraints/ConstraintErrors.swift b/Sources/PoieticCore/Constraints/ConstraintErrors.swift index eaba45cf..90b5638b 100644 --- a/Sources/PoieticCore/Constraints/ConstraintErrors.swift +++ b/Sources/PoieticCore/Constraints/ConstraintErrors.swift @@ -83,6 +83,52 @@ public enum ObjectTypeError: Error, Equatable, CustomStringConvertible, DesignIs } } +extension ObjectTypeError /*: IssueProtocol */ { + public var message: String { description } + public var hints: [String] { ["Consult the metamodel"] } + + public func asObjectIssue() -> Issue { + switch self { + case let .missingTraitAttribute(attribute, trait): + Issue( + identifier: "missing_trait_attribute", + severity: .fatal, + system: "Validation", + message: self.description, + details: [ + "attribute": Variant(attribute.name), + "trait": Variant(trait) + ]) + case let .typeMismatch(attribute, _): + Issue( + identifier: "attribute_type_mismatch", + severity: .fatal, + system: "Validation", + message: self.description, + details: [ + "attribute": Variant(attribute.name), + "expected_type": Variant(attribute.type.description) + ]) + case let .unknownType(type): + Issue( + identifier: "unknown_type", + severity: .fatal, + system: "Validation", + message: self.description, + details: ["type": Variant(type)]) + case let .structureMismatch(type): + Issue( + identifier: "structure_mismatch", + severity: .fatal, + system: "Validation", + message: self.description, + details: [ + "expected_structure": Variant(type.rawValue) + ]) + } + } +} + /// Collection of object type violation errors produced when checking object /// types. @@ -97,11 +143,33 @@ public struct ObjectTypeErrorCollection: Error { } } +public enum FrameValidationError: Error { + /// Structural references such as edge endpoints, parent-child are invalid. + /// + /// When this error happens, it is not possible to do further diagnostics. It usually means + /// a programming error. + /// s + case brokenStructuralIntegrity(StructuralIntegrityError) + case objectTypeError(ObjectID, ObjectTypeError) + case edgeRuleViolation(ObjectID, EdgeRuleViolation) + case constraintViolation(ConstraintViolation) + + /// Flag whether the caller can diagnose details about constraint violations using + /// ``ConstraintChecker/diagnose(_:)`` after this error. + /// + public var canDiagnoseConstraints: Bool { + switch self { + case .brokenStructuralIntegrity: false + default: true + } + } +} + /// Error generated when a frame is checked for constraints and object types. /// /// This error is produced by the ``ConstraintChecker/check(_:)``. /// -public struct FrameValidationError: Error { +public struct FrameValidationResult: Sendable { /// List of constraint violations. /// /// - SeeAlso: ``Metamodel/constraints``, ``Constraint``. @@ -136,41 +204,53 @@ public struct FrameValidationError: Error { self.objectErrors = objectErrors self.edgeRuleViolations = edgeRuleViolations } + + /// True if there are no violations. + public var isValid: Bool { + violations.isEmpty && objectErrors.isEmpty && edgeRuleViolations.isEmpty + } + - /// Converts the validation error into an application oriented design issue. + /// Convert violations to object issues. /// - /// This method is used when the errors are to be presented by an application. For example - /// in an error browser or by an object error inspector. + /// This method is used for unified error output. /// - public func asDesignIssueCollection() -> DesignIssueCollection { - var result: DesignIssueCollection = DesignIssueCollection() - // TODO: Use object-less design issues + public func violationsAsIssues() -> [Issue] { + var result: [Issue] = [] for violation in violations { - for object in violation.objects { - let issue = DesignIssue( - domain: .validation, - severity: .error, - identifier: "constraint_violation", - message: violation.constraint.abstract ?? "Constraint violation", - details: [ - "constraint": Variant(violation.constraint.name) - ] + let constraint = violation.constraint + let message = constraint.name + + (constraint.abstract.map { ": " + $0 } ?? "") + let issue = Issue( + identifier: "constraint_violation:", + severity: .error, + system: "Validation", + message: message, + relatedObjects: violation.objects ) - result.append(issue, for: object) - } + result.append(issue) } - + return result + } + + /// Convert object errors and edge rule violations to object issues. + /// + /// This method is used for unified error output. + /// + public func objectIssues() -> [ObjectID:[Issue]] { + var result: [ObjectID:[Issue]] = [:] for (id, errors) in objectErrors { for error in errors { - result.append(error.asDesignIssue(), for: id) + let issue = error.asObjectIssue() + result[id, default: []].append(issue) } } for (id, errors) in edgeRuleViolations { for error in errors { - result.append(error.asDesignIssue(), for: id) + let issue = error.asObjectIssue() + result[id, default: []].append(issue) } } - return result } } diff --git a/Sources/PoieticCore/Constraints/EdgeRule.swift b/Sources/PoieticCore/Constraints/EdgeRule.swift index 446d6715..e5c1b990 100644 --- a/Sources/PoieticCore/Constraints/EdgeRule.swift +++ b/Sources/PoieticCore/Constraints/EdgeRule.swift @@ -31,7 +31,7 @@ public enum EdgeDirection: Sendable, CustomStringConvertible { } } -public enum EdgeRuleViolation: Error, CustomStringConvertible, DesignIssueConvertible { +public enum EdgeRuleViolation: Error, CustomStringConvertible { case edgeNotAllowed case noRuleSatisfied case cardinalityViolation(EdgeRule, EdgeDirection) @@ -43,35 +43,42 @@ public enum EdgeRuleViolation: Error, CustomStringConvertible, DesignIssueConver case let .cardinalityViolation(rule, direction): "Cardinality violation for rule \(rule) direction \(direction)" } } +} +extension EdgeRuleViolation /*: IssueProtocol */ { + public var message: String { description } + public var hints: [String] { ["Consult the metamodel"] } - public func asDesignIssue() -> DesignIssue { + public func asObjectIssue() -> Issue { switch self { case .edgeNotAllowed: - DesignIssue(domain: .validation, - severity: .error, - identifier: "edge_not_allowed", - message: description, - hint: nil, - details: [:]) + Issue( + identifier: "edge_not_allowed", + severity: .error, + system: "EdgeRule", + message: self.description, + hints: self.hints + ) case .noRuleSatisfied: - DesignIssue(domain: .validation, - severity: .error, - identifier: "no_edge_rule_satisfied", - message: description, - hint: nil, - details: [:]) + Issue( + identifier: "no_edge_rule_satisfied", + severity: .error, + system: "EdgeRule", + message: self.description, + hints: self.hints + ) case let .cardinalityViolation(rule, direction): - DesignIssue(domain: .validation, - severity: .error, - identifier: "edge_cardinality_violated", - message: description, - hint: nil, - details: [ - "incoming_predicate": Variant(rule.incoming.description), - "outgoing_predicate": Variant(rule.outgoing.description), - "direction": Variant(direction.description) - ] - ) + Issue( + identifier: "edge_cardinality_violated", + severity: .error, + system: "EdgeRule", + message: self.description, + hints: self.hints, + details: [ + "incoming_predicate": Variant(rule.incoming.description), + "outgoing_predicate": Variant(rule.outgoing.description), + "direction": Variant(direction.description) + ] + ) } } } @@ -186,7 +193,7 @@ public struct EdgeRule: Sendable, CustomStringConvertible { public func match(_ edge: EdgeObject, in frame: some Frame) -> Bool { return match(edge.object.type, origin: edge.originObject, target: edge.targetObject, in: frame) } - + /// Validates whether the given edge type with given origin and target matches the rule. /// /// The edge matches the rule if all of the following is satisfied: diff --git a/Sources/PoieticCore/Design/Component.swift b/Sources/PoieticCore/Design/Component.swift index c3b763f6..206783a4 100644 --- a/Sources/PoieticCore/Design/Component.swift +++ b/Sources/PoieticCore/Design/Component.swift @@ -16,7 +16,20 @@ /// contents can be reconstructed from other information present in the /// design. /// +/// This is just an annotation protocol, has no requirements. +/// public protocol Component { + // Empty, just an annotation. +} + +/// Component that is associated with the whole frame, not with particular object. +/// +/// This is just an annotation protocol, has no requirements. +/// +/// - SeeAlso: ``RuntimeEntityID/Frame`` +/// +public protocol FrameComponent: Component { + // Empty, just an annotation. } /// Collection of components of an object. diff --git a/Sources/PoieticCore/Design/Design.swift b/Sources/PoieticCore/Design/Design.swift index d1bed462..c95c6c3b 100644 --- a/Sources/PoieticCore/Design/Design.swift +++ b/Sources/PoieticCore/Design/Design.swift @@ -116,9 +116,14 @@ public class Design { /// Generator of entity IDs. /// public let identityManager: IdentityManager + + /// Sequence for ephemeral entities + internal var ephemeralSequence: UInt64 var _objectSnapshots: EntityTable - var _stableFrames: EntityTable + + /// Frames that have been accepted and are in fact validated with the metamodel. + var _validatedFrames: EntityTable var _objects: EntityTable var _transientFrames: [FrameID: TransientFrame] @@ -148,10 +153,9 @@ public class Design { /// history. /// public var currentFrame: DesignFrame? { - guard let currentFrameID else { - return nil - } - return _stableFrames[currentFrameID] + guard let currentFrameID, + let frame = _validatedFrames[currentFrameID] else { return nil } + return frame } /// List of IDs of frames that can undone. @@ -182,8 +186,9 @@ public class Design { /// - SeeAlso: ``createFrame(deriving:id:)`` /// public init(metamodel: Metamodel = Metamodel()) { + self.ephemeralSequence = RuntimeEntityID.FirstEphemeralIDValue self._objectSnapshots = EntityTable() - self._stableFrames = EntityTable() + self._validatedFrames = EntityTable() self._objects = EntityTable() self._transientFrames = [:] self._namedFrames = [:] @@ -192,6 +197,15 @@ public class Design { self.metamodel = metamodel self.identityManager = IdentityManager() } + + // MARK: - Identity + + internal func reserveRuntimeID() -> RuntimeEntityID { + // TODO: Use lock once we are multi-thread ready (we are not) + let value = ephemeralSequence + ephemeralSequence += 1 + return .ephemeral(EphemeralID(rawValue: value)) + } // MARK: - Snapshots /// True if the design does not contain any stable frames nor object snapshots. @@ -201,7 +215,7 @@ public class Design { /// - Note: Transient frames are not counted as they are not persisted. /// public var isEmpty: Bool { - return _objectSnapshots.isEmpty && _stableFrames.isEmpty + return _objectSnapshots.isEmpty && _validatedFrames.isEmpty } /// Get a collection of all stable snapshots in all stable frames. @@ -229,7 +243,7 @@ public class Design { /// List of all stable frames in the design. /// public var frames: some Collection { - return _stableFrames.items + return _validatedFrames.items } /// Get a stable frame with given ID. @@ -238,13 +252,14 @@ public class Design { /// otherwise `nil`. /// public func frame(_ id: FrameID) -> DesignFrame? { - return _stableFrames[id] + guard let frame = _validatedFrames[id] else { return nil } + return frame } /// Test whether the design contains a stable frame with given ID. /// public func containsFrame(_ id: FrameID) -> Bool { - return _stableFrames[id] != nil + return _validatedFrames[id] != nil } /// Get a frame from the list of named frames. @@ -254,9 +269,40 @@ public class Design { /// - SeeAlso: ``accept(_:replacingName:)`` /// public func frame(name: String) -> DesignFrame? { - return _namedFrames[name] + guard let frame = _namedFrames[name] else { return nil } + return frame } + /// Create a new empty frame. + /// + /// - Parameters: + /// - id: Proposed ID of the new frame. Must be unique and must not + /// already exist in the design. If not provided, a new unique ID + /// is generated. + /// + /// - Precondition: The design must not contain a frame with `id`. + /// + /// - SeeAlso: ``accept(_:appendHistory:)``, ``discard(_:)`` + /// + @discardableResult + public func _createFrame(id: FrameID? = nil) -> TransientFrame { + // TODO: Throw some identity error here + let actualID: FrameID + if let id { + let success = identityManager.reserve(id) + precondition(success, "ID already used (\(id)") + actualID = id + } + else { + actualID = identityManager.reserveNew() + } + + let trans = TransientFrame(design: self, id: actualID) + _transientFrames[actualID] = trans + return trans + } + + /// Create a new frame or derive a frame from an existing frame. /// /// - Parameters: @@ -277,7 +323,7 @@ public class Design { /// - SeeAlso: ``accept(_:appendHistory:)``, ``discard(_:)`` /// @discardableResult - public func createFrame(deriving original: DesignFrame? = nil, + public func createFrame(deriving original: (any Frame)? = nil, id: FrameID? = nil) -> TransientFrame { // TODO: Throw some identity error here let actualID: FrameID @@ -291,10 +337,8 @@ public class Design { } let derived: TransientFrame - if let original { precondition(original.design === self, "Trying to clone a frame from different design") - derived = TransientFrame(design: self, id: actualID, snapshots: original.snapshots) } else { @@ -330,11 +374,11 @@ public class Design { /// - Precondition: The frame with given ID must exist in the design. /// public func removeFrame(_ id: FrameID) { - guard let frame = _stableFrames[id] else { + guard let frame = _validatedFrames[id] else { preconditionFailure("Unknown frame ID \(id)") } // Currently no one can retain a frame. - assert(_stableFrames.referenceCount(id) == 1) + assert(_validatedFrames.referenceCount(id) == 1) undoList.removeAll { $0 == id } redoList.removeAll { $0 == id } @@ -360,7 +404,7 @@ public class Design { _release(snapshot: snapshot.snapshotID) } - _stableFrames.remove(id) + _validatedFrames.remove(id) identityManager.free(id) } @@ -413,8 +457,9 @@ public class Design { /// exist as a transient frame in the design. /// @discardableResult - public func accept(_ frame: TransientFrame, appendHistory: Bool = true) throws (StructuralIntegrityError) -> DesignFrame { - let stableFrame = try validateAndInsert(frame) + public func accept(_ frame: TransientFrame, appendHistory: Bool = true) + throws (FrameValidationError) -> DesignFrame { + let validated = try validateAndInsert(frame) if appendHistory { if let currentFrameID { @@ -425,9 +470,9 @@ public class Design { } redoList.removeAll() } - currentFrameID = frame.id + currentFrameID = validated.id - return stableFrame + return validated } /// Accept a frame as a named frame, replacing the previous frame with the same name. @@ -455,7 +500,8 @@ public class Design { /// - SeeAlso: ``frame(name:)`` /// @discardableResult - public func accept(_ frame: TransientFrame, replacingName name: String) throws (StructuralIntegrityError) -> DesignFrame { + public func accept(_ frame: TransientFrame, replacingName name: String) + throws (FrameValidationError) -> DesignFrame { let old = _namedFrames[name] let stable = try validateAndInsert(frame) @@ -474,17 +520,25 @@ public class Design { _namedFrames[name] = frame(frameID) } - internal func validateAndInsert(_ frame: TransientFrame) throws (StructuralIntegrityError) -> DesignFrame { + internal func validateAndInsert(_ frame: TransientFrame) throws (FrameValidationError) -> DesignFrame { precondition(frame.design === self) precondition(frame.state == .transient) - precondition(!_stableFrames.contains(frame.id), "Duplicate frame ID \(frame.id)") + precondition(!_validatedFrames.contains(frame.id), "Duplicate frame ID \(frame.id)") precondition(_transientFrames[frame.id] != nil, "No transient frame with ID \(frame.id)") - try frame.validateStructure() - let snapshots: [ObjectSnapshot] = frame.snapshots + do { + try StructuralValidator.validate(snapshots: snapshots, in: frame) + } + catch { + throw .brokenStructuralIntegrity(error) + } + let stableFrame = DesignFrame(design: self, id: frame.id, snapshots: snapshots) + let checker = ConstraintChecker(metamodel) + try checker.validate(stableFrame) + _transientFrames[frame.id] = nil unsafeInsert(stableFrame) @@ -513,7 +567,7 @@ public class Design { /// public func unsafeInsert(_ frame: DesignFrame) { precondition(frame.design === self) - precondition(!_stableFrames.contains(frame.id), "Duplicate frame ID \(frame.id)") + precondition(!_validatedFrames.contains(frame.id), "Duplicate frame ID \(frame.id)") precondition(_transientFrames[frame.id] == nil) for snapshot in frame.snapshots { @@ -526,24 +580,9 @@ public class Design { _objectSnapshots.insertOrRetain(snapshot) } - _stableFrames.insert(frame) + _validatedFrames.insert(frame) } - @discardableResult - public func validate(_ frame: DesignFrame, metamodel: Metamodel? = nil) throws (FrameValidationError) -> ValidatedFrame { - precondition(frame.design === self) - precondition(_stableFrames.contains(frame.id)) - - let validationMetamodel = metamodel ?? self.metamodel - - let checker = ConstraintChecker(validationMetamodel) - try checker.check(frame) - - let validated = ValidatedFrame(frame, metamodel: validationMetamodel) - - return validated - } - /// Flag whether the design has any un-doable frames. /// /// - SeeAlso: ``undo(to:)``, ``redo(to:)``, ``canRedo`` diff --git a/Sources/PoieticCore/Design/DesignFrame.swift b/Sources/PoieticCore/Design/DesignFrame.swift index 3e76c34e..34c9c9cd 100644 --- a/Sources/PoieticCore/Design/DesignFrame.swift +++ b/Sources/PoieticCore/Design/DesignFrame.swift @@ -39,6 +39,8 @@ public final class DesignFrame: Frame, Identifiable { internal let _lookup: [ObjectID:ObjectSnapshot] @usableFromInline internal let _graph: Graph + + public var isEmpty: Bool { _snapshots.isEmpty } /// Create a new stable frame with given ID and with list of snapshots. /// @@ -87,7 +89,7 @@ public final class DesignFrame: Frame, Identifiable { } /// Filters the IDs and returns only those that are contained in the frame. - public func contained(_ ids: [ObjectID]) -> [ObjectID] { + public func contained(_ ids: some Collection) -> [ObjectID] { ids.filter { _lookup[$0] != nil } } diff --git a/Sources/PoieticCore/Design/DesignIssue.swift b/Sources/PoieticCore/Design/DesignIssue.swift index 2b5ec484..d680cdc3 100644 --- a/Sources/PoieticCore/Design/DesignIssue.swift +++ b/Sources/PoieticCore/Design/DesignIssue.swift @@ -47,6 +47,7 @@ public struct DesignIssueCollection: Sendable { public struct DesignIssue: Sendable, CustomStringConvertible { // TODO: Add priority/weight (to know which display first, or if only one is to be displayed) + // FIXME: Replace with: system: String or action: String public enum Domain: Sendable, CustomStringConvertible { /// Issue occurred during validation. /// @@ -125,6 +126,7 @@ public struct DesignIssue: Sendable, CustomStringConvertible { /// /// - `attribute`: Name of an attribute that caused the issue. /// - `trait`: Name of a trait. + /// - `formula`: Arithmetic expression. See ``ExpressionSyntaxError``. /// /// - Note: The meaning of keys and values are not formalised yet. public let details: [String:Variant] @@ -164,3 +166,113 @@ public struct DesignIssue: Sendable, CustomStringConvertible { public protocol DesignIssueConvertible: Error { func asDesignIssue() -> DesignIssue } + +// TODO: Rename to ObjectIssue (once we get rid of old object) +public protocol IssueProtocol: Error, Sendable, Equatable { + var message: String { get } + var hints: [String] { get } + +} + +/// Representation of an issue in the design caused by the user. +/// +public struct Issue: Sendable, CustomStringConvertible { + public enum Severity: Sendable, CustomStringConvertible { + /// Type of an issue that prevents further processing of the design. + case error + /// type of an issue that allows further processing of the design, although the result + /// quality or correctness is not guaranteed. + case warning + case fatal + + public var description: String { + switch self { + case .error: "error" + case .warning: "warning" + case .fatal: "fatal" + } + } + } + + /// Identifier of the issue. + /// + /// Used to look-up the issue in a documentation or for localisation purposes. + /// + public let identifier: String + + /// Severity of the issue. + /// + /// Typical issue severity is ``Severity/fatal`` which means that the design can not be used + /// in a meaningful way, neither it can be processed further. + /// + public let severity: Severity + + // TODO: Rename to context + /// Name of a system that caused the issue + /// + public let system: String + + public let error: (any IssueProtocol)? + + public var message: String + public var hints: [String] + public var relatedObjects: [ObjectID] + + /// Details about the issue that applications can present or use. + /// + /// Known keys: + /// + /// - `attribute`: Name of an attribute that caused the issue. + /// - `trait`: Name of a trait. + /// + /// - Note: The meaning of keys and values are not formalised yet. + public let details: [String:Variant] + + /// Create a new design issue. + /// + /// - Parameters: + /// - domain: Domain where the issue occurred. + /// - severity: Indicator noting how processable the design is. + /// - identifier: Error code. + /// - message: User-oriented error description. Use ordinary user language here, not + /// developer's language. + /// - hint: Information about how the issue can be corrected or where to investigate further. + /// - details: dictionary of details that might be presented by the application to the user. + /// + public init(identifier: String, + severity: Severity = .error, + system: any System, + error: any IssueProtocol, + relatedObjects: [ObjectID] = [], + details: [String : Variant] = [:]) { + self.identifier = identifier + self.severity = severity + self.system = String(describing: type(of: system)) + self.error = error + self.message = error.message + self.hints = error.hints + self.relatedObjects = relatedObjects + self.details = details + } + public init(identifier: String, + severity: Severity = .error, + system: String, + message: String, + hints: [String] = [], + relatedObjects: [ObjectID] = [], + details: [String : Variant] = [:]) { + self.identifier = identifier + self.severity = severity + self.system = system + self.error = nil + self.message = message + self.hints = hints + self.relatedObjects = relatedObjects + self.details = details + } + + public var description: String { + return "\(severity)[\(system),\(identifier)]: \(message)" + } + +} diff --git a/Sources/PoieticCore/Design/Frame.swift b/Sources/PoieticCore/Design/Frame.swift index 82e22574..4c2cbba3 100644 --- a/Sources/PoieticCore/Design/Frame.swift +++ b/Sources/PoieticCore/Design/Frame.swift @@ -41,17 +41,6 @@ public protocol Frame: /// subscript(objectID: ObjectID) -> ObjectSnapshot? { get } - - /// Get a list of broken references. - /// - /// The list contains IDs from edges and parent-child relationships that are not present in the - /// frame. - /// - /// This convenience method is used for debugging. - /// - func brokenReferences() -> [ObjectID] - - /// Get objects of given type. /// func filter(type: ObjectType) -> [ObjectSnapshot] @@ -83,175 +72,6 @@ extension Frame { self.object(id) } } - /// Get a list of missing IDs from the list of IDs - public func missing(_ ids: [ObjectID]) -> [ObjectID] { - return ids.filter { !contains($0) } - } - - - /// Get a list of object IDs that are referenced within the frame - /// but do not exist in the frame. - /// - /// Frame with broken references can not be made stable and accepted - /// by the design. - /// - /// The following references from the snapshot are being considered: - /// - /// - If the structure type is an edge (``Structure/edge(_:_:)``) - /// then the origin and target is considered. - /// - All children – ``ObjectSnapshotProtocol/children``. - /// - The object's parent – ``ObjectSnapshotProtocol/parent``. - /// - /// - Note: This is semi-internal function to validate correct workings - /// of the system. You should rarely use it. Typical scenario when you - /// want to use this function is when you are constructing a frame - /// in an unsafe way. - /// - /// - SeeAlso: ``Frame/brokenReferences(snapshot:)`` - /// - public func brokenReferences() -> [ObjectID] { - // NOTE: Sync with brokenReferences(snapshot:) - // - var broken: Set = [] - - for snapshot in snapshots { - if case let .edge(origin, target) = snapshot.structure { - if !contains(origin) { - broken.insert(origin) - } - if !contains(target) { - broken.insert(target) - } - } - broken.formUnion(snapshot.children.filter { !contains($0) }) - if let parent = snapshot.parent, !contains(parent) { - broken.insert(parent) - } - } - - return Array(broken) - } - - /// Return a list of objects that the provided object refers to and - /// that do not exist within the frame. - /// - /// Frame with broken references can not be made stable and accepted - /// by the design. - /// - /// The following references from the snapshot are being considered: - /// - /// - If the structure type is an edge (``Structure/edge(_:_:)``) - /// then the origin and target is considered. - /// - All children – ``ObjectSnapshotProtocol/children``. - /// - The object's parent – ``ObjectSnapshotProtocol/parent``. - /// - /// - SeeAlso: ``Frame/brokenReferences()`` - /// - public func brokenReferences(snapshot: ObjectSnapshot) -> [ObjectID] { - // NOTE: Sync with brokenReferences() for all snapshots within the frame - // - var broken: Set = [] - - if case let .edge(origin, target) = snapshot.structure { - if !contains(origin) { - broken.insert(origin) - } - if !contains(target) { - broken.insert(target) - } - } - broken.formUnion(snapshot.children.filter { !contains($0) }) - if let parent = snapshot.parent, !contains(parent) { - broken.insert(parent) - } - - return Array(broken) - } - - /// Validate structural references. - /// - /// The method validates structural integrity of objects: - /// - /// - Edge endpoints must exist within the frame. - /// - Children-parent relationship must be mutual. - /// - There must be no parent-child cycle. - /// - /// If the validation fails, detailed information can be provided by the ``brokenReferences()`` - /// method. - /// - /// - SeeAlso: ``Design/accept(_:appendHistory:)``, ``Design/validate(_:metamodel:)`` - /// - Precondition: The frame must be in transient state – must not be - /// previously accepted or discarded. - /// - public func validateStructure() throws (StructuralIntegrityError) { - var parents: [(parent: ObjectID, child: ObjectID)] = [] - - // Integrity checks - for checked in self.snapshots { - switch checked.structure { - case .unstructured: break // Nothing to validate. - case .node: break // Nothing to validate. - case let .edge(originID, targetID): - guard let origin = self[originID], - let target = self[targetID] - else { - throw .brokenStructureReference - } - guard origin.structure == .node && target.structure == .node else { - throw .edgeEndpointNotANode - } - case let .orderedSet(owner, ids): - guard self.contains(owner) && ids.allSatisfy({contains($0)}) else { - throw .brokenStructureReference - } - } - - for childID in checked.children { - guard let child = self[childID] else { - throw .brokenChild - } - guard child.parent == checked.objectID else { - throw .parentChildMismatch - } - } - - if let parentID = checked.parent { - guard let parent = self[parentID] else { - throw .brokenParent - } - - guard parent.children.contains(checked.objectID) else { - throw .parentChildMismatch - } - parents.append((parent: parentID, child: checked.objectID)) - } - } - - // Map: child -> parent - - let children = Set(parents.map { $0.child }) - var tops: [ObjectID] = parents.compactMap { - if children.contains($0.parent) { - nil - } - else { - $0.parent - } - } - - while !tops.isEmpty { - let topParent = tops.removeFirst() - for (_, child) in parents.filter({ $0.parent == topParent }) { - tops.append(child) - } - parents.removeAll { $0.parent == topParent } - } - - if !parents.isEmpty { - throw .parentChildCycle - } - } - /// Get first object of given type. /// /// This method is used to find singleton objects, for example @@ -358,7 +178,6 @@ extension Frame { // MARK: - Graph Implementations - extension Frame { /// Get object by a name, if the object contains a named component. /// diff --git a/Sources/PoieticCore/Design/Selection.swift b/Sources/PoieticCore/Design/Selection.swift index afa5eacd..00dd0ff9 100644 --- a/Sources/PoieticCore/Design/Selection.swift +++ b/Sources/PoieticCore/Design/Selection.swift @@ -18,7 +18,7 @@ /// When a selection is preserved between changes, it is recommended to sanitise the objects /// in the selection using the ``Frame/contained(_:)`` function. /// -public final class Selection: Collection { +public final class Selection: Collection, Component { public typealias Index = [ObjectID].Index /// List of object IDs contained in the selection. diff --git a/Sources/PoieticCore/Design/StructuralValidator.swift b/Sources/PoieticCore/Design/StructuralValidator.swift new file mode 100644 index 00000000..16dda859 --- /dev/null +++ b/Sources/PoieticCore/Design/StructuralValidator.swift @@ -0,0 +1,202 @@ +// +// StructuralValidator.swift +// poietic-core +// +// Created by Stefan Urbanek on 06/11/2025. +// + +/// Namespace for snapshot and frame validation methods. +/// +public struct StructuralValidator { + /// Validates that snapshot's structural references within a context of a frame. + /// + /// Can be used to check whether the snapshot can be added to a frame. + /// + /// What is validated: + /// + /// - Parent exist in the frame. + /// - Children exist in the frame. + /// - If it is an edge: whether origin and target exist in the frame and are of type + /// ``StructuralType/node``. + /// - If it is an ordered set: whether the owner and all IDs exist in the frame. + /// + public static func validate(_ object: some ObjectProtocol, in frame: some Frame) + throws (StructuralIntegrityError) { + switch object.structure { + case .unstructured: break // Nothing to validate. + case .node: break // Nothing to validate. + case let .edge(originID, targetID): + guard let origin = frame[originID], + let target = frame[targetID] + else { + throw .brokenStructureReference + } + guard origin.structure == .node && target.structure == .node else { + throw .edgeEndpointNotANode + } + case let .orderedSet(owner, ids): + guard frame.contains(owner) && ids.allSatisfy({frame.contains($0)}) else { + throw .brokenStructureReference + } + } + + for childID in object.children { + guard let child = frame[childID] else { + throw .brokenChild + } + guard child.parent == object.objectID else { + throw .parentChildMismatch + } + } + + if let parentID = object.parent { + guard let parent = frame[parentID] else { + throw .brokenParent + } + + guard parent.children.contains(object.objectID) else { + throw .parentChildMismatch + } + } + } + + /// Return a list of objects that the provided object refers to and + /// that do not exist within the frame. + /// + /// Frame with broken references can not be made stable and accepted + /// by the design. + /// + /// The following references from the snapshot are being considered: + /// + /// - If the structure type is an edge (``Structure/edge(_:_:)``) + /// then the origin and target is considered. + /// - All children – ``ObjectSnapshotProtocol/children``. + /// - The object's parent – ``ObjectSnapshotProtocol/parent``. + /// + /// - SeeAlso: ``Frame/brokenReferences()`` + /// + public static func brokenReferences(_ object: some ObjectProtocol,in frame: some Frame) -> Set { + // NOTE: Sync with brokenReferences() for all snapshots within the frame + // + var broken: Set = [] + + switch object.structure { + case .unstructured: break // Nothing broken. + case .node: break // Nothing broken. + case let .edge(originID, targetID): + if !frame.contains(originID) { + broken.insert(originID) + } + if !frame.contains(targetID) { + broken.insert(targetID) + } + case let .orderedSet(owner, ids): + if !frame.contains(owner) { + broken.insert(owner) + } + for id in ids { + if !frame.contains(id) { + broken.insert(id) + } + } + } + + if let parent = object.parent, !frame.contains(parent) { + broken.insert(parent) + } + + for id in object.children { + if !frame.contains(id) { + broken.insert(id) + } + } + + return broken + } + + /// Validates complete structural integrity of a collection of snapshots + /// + /// The method validates structural integrity of objects: + /// + /// - Edge endpoints must exist within the frame and must be nodes. + /// - Ordered set owner and references must exist in the frame. + /// - Children-parent relationship must be mutual. + /// - There must be no parent-child cycle. + /// + /// If the validation fails, detailed information can be provided by the ``brokenReferences()`` + /// method. + /// + /// - SeeAlso: ``Design/accept(_:appendHistory:)``, ``Design/validate(_:metamodel:)`` + /// - Precondition: The frame must be in transient state – must not be + /// previously accepted or discarded. + /// + static func validate(snapshots: [ObjectSnapshot], in frame: some Frame) + throws (StructuralIntegrityError) { + // TODO: This is not quite correct, we should be validating within snapshots themselves as well, or not? + // Check for parent-child cycles using topological traversal + var parents: [(parent: ObjectID, child: ObjectID)] = [] + + for object in snapshots { + try validate(object, in: frame) + if let parentID = object.parent { + parents.append((parent: parentID, child: object.objectID)) + } + } + + // Map: child -> parent + let children = Set(parents.map { $0.child }) + var tops: [ObjectID] = parents.compactMap { + if children.contains($0.parent) { + nil + } + else { + $0.parent + } + } + + while !tops.isEmpty { + let topParent = tops.removeFirst() + for (_, child) in parents.filter({ $0.parent == topParent }) { + tops.append(child) + } + parents.removeAll { $0.parent == topParent } + } + + if !parents.isEmpty { + throw .parentChildCycle + } + } + + /// Get a list of object IDs that are referenced within the frame + /// but do not exist in the frame. + /// + /// Frame with broken references can not be made stable and accepted + /// by the design. + /// + /// The following references from the snapshot are being considered: + /// + /// - If the structure type is an edge (``Structure/edge(_:_:)``) + /// then the origin and target is considered. + /// - All children – ``ObjectSnapshotProtocol/children``. + /// - The object's parent – ``ObjectSnapshotProtocol/parent``. + /// + /// - Note: This is semi-internal function to validate correct workings + /// of the system. You should rarely use it. Typical scenario when you + /// want to use this function is when you are constructing a frame + /// in an unsafe way. + /// + /// - SeeAlso: ``Frame/brokenReferences(snapshot:)`` + /// + public func brokenReferences(_ snapshots: [ObjectSnapshot], in frame: some Frame) -> Set { + // NOTE: Sync with brokenReferences(snapshot:) + // + var broken: Set = [] + + for snapshot in snapshots { + broken.formUnion(Self.brokenReferences(snapshot, in: frame)) + } + + return broken + } + +} diff --git a/Sources/PoieticCore/Design/TransientFrame.swift b/Sources/PoieticCore/Design/TransientFrame.swift index 13bcb636..ce53e269 100644 --- a/Sources/PoieticCore/Design/TransientFrame.swift +++ b/Sources/PoieticCore/Design/TransientFrame.swift @@ -320,10 +320,8 @@ public final class TransientFrame: Frame { /// public func insert(_ snapshot: ObjectSnapshot) { // TODO: Make insert() function throwing (StructuralIntegrityError) - // Check for referential integrity - // TODO: Validate structure vs. type do { - try validateStructure(snapshot) + try StructuralValidator.validate(snapshot, in: self) } catch { preconditionFailure("Structural integrity error") @@ -331,28 +329,6 @@ public final class TransientFrame: Frame { unsafeInsert(snapshot) } - public func validateStructure(_ snapshot: ObjectSnapshot) throws (StructuralIntegrityError) { - switch snapshot.structure { - case .node, .unstructured: break - case let .edge(origin, target): - guard contains(origin) && contains(target) else { - throw .brokenStructureReference - } - case let .orderedSet(owner, items): - guard contains(owner) && items.allSatisfy({ contains($0) }) else { - throw .brokenStructureReference - } - } - guard snapshot.children.allSatisfy({ contains($0) }) else { - throw .brokenChild - - } - if let parent = snapshot.parent { - guard contains(parent) else { - throw .brokenParent - } - } - } /// Unsafely insert a snapshot to the frame, not checking for structural /// references. diff --git a/Sources/PoieticCore/Design/ValidatedFrame.swift b/Sources/PoieticCore/Design/ValidatedFrame.swift deleted file mode 100644 index e04c5b66..00000000 --- a/Sources/PoieticCore/Design/ValidatedFrame.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// ValidatedFrame.swift -// poietic-core -// -// Created by Stefan Urbanek on 04/03/2025. -// - -public struct ValidatedFrame: Frame { - public typealias Snapshot = ObjectSnapshot - - /// Stable frame that was validated. - public let wrapped: DesignFrame - - /// Metamodel according to which the frame was validated. - public let metamodel: Metamodel - - internal init(_ wrapped: DesignFrame, metamodel: Metamodel) { - self.wrapped = wrapped - self.metamodel = metamodel - } - - @inlinable - public var design: Design { wrapped.design } - - @inlinable - public var id: FrameID { wrapped.id } - - @inlinable - public var snapshots: [ObjectSnapshot] { wrapped.snapshots } - - @inlinable - public var objectIDs: [ObjectID] { wrapped.objectIDs } - - @inlinable - public func contains(_ id: ObjectID) -> Bool { - wrapped.contains(id) - } - - @inlinable - public func object(_ id: ObjectID) -> ObjectSnapshot? { - wrapped.object(id) - } - - @inlinable - public var nodeKeys: [ObjectID] { wrapped.nodeKeys } - @inlinable - public var edgeKeys: [ObjectID] { wrapped.edgeKeys } - @inlinable - public var edges: [EdgeObject] { wrapped.edges } - - @inlinable - public func outgoing(_ origin: NodeKey) -> [Edge] { - return wrapped.outgoing(origin) - } - - @inlinable - public func incoming(_ target: NodeKey) -> [Edge] { - return wrapped.incoming(target) - } -} diff --git a/Sources/PoieticCore/Expression/Expression.swift b/Sources/PoieticCore/Expression/Expression.swift index 1bec5548..0d194196 100644 --- a/Sources/PoieticCore/Expression/Expression.swift +++ b/Sources/PoieticCore/Expression/Expression.swift @@ -21,6 +21,7 @@ public typealias UnboundExpression = ArithmeticExpression /// - SeeAlso: ``ExpressionParser``, ``UnboundExpression`` /// public indirect enum ArithmeticExpression { + // TODO: Use operator table for operators public typealias LiteralValue = Variant /// Type of a reference to a variable. diff --git a/Sources/PoieticCore/Expression/ExpressionParser.swift b/Sources/PoieticCore/Expression/ExpressionParser.swift index b9e901ed..947c7f23 100644 --- a/Sources/PoieticCore/Expression/ExpressionParser.swift +++ b/Sources/PoieticCore/Expression/ExpressionParser.swift @@ -27,6 +27,12 @@ public enum ExpressionSyntaxError: Error, Equatable, CustomStringConvertible { } } +extension ExpressionSyntaxError: IssueProtocol { + public var message: String { "Formula error: " + self.description } + public var hints: [String] { ["Check the formula syntax"] } +} + + /// Abstract syntax tree of arithmetic expression. /// public indirect enum ExpressionAST { diff --git a/Sources/PoieticCore/Foreign/DesignExtractor.swift b/Sources/PoieticCore/Foreign/DesignExtractor.swift index 510ae258..e79986f7 100644 --- a/Sources/PoieticCore/Foreign/DesignExtractor.swift +++ b/Sources/PoieticCore/Foreign/DesignExtractor.swift @@ -109,7 +109,7 @@ public class DesignExtractor { /// Create a raw frame from a design frame. /// - public func extract(_ frame: DesignFrame) -> RawFrame { + public func extract(_ frame: some Frame) -> RawFrame { return RawFrame( id: .id(frame.id.rawValue), snapshots: frame.snapshots.map { .id($0.snapshotID.rawValue) } @@ -131,7 +131,7 @@ public class DesignExtractor { /// - Missing parent is set to `nil`. /// - Snapshots not present in the frame are ignored. /// - public func extractPruning(objects objectIDs: [ObjectID], frame: DesignFrame) -> [RawSnapshot] { + public func extractPruning(objects objectIDs: [ObjectID], frame: some Frame) -> [RawSnapshot] { let knownIDs: Set = Set(objectIDs) var result: [RawSnapshot] = [] diff --git a/Sources/PoieticCore/Foreign/DesignLoader+reservation.swift b/Sources/PoieticCore/Foreign/DesignLoader+reservation.swift index 619a313a..31cf0459 100644 --- a/Sources/PoieticCore/Foreign/DesignLoader+reservation.swift +++ b/Sources/PoieticCore/Foreign/DesignLoader+reservation.swift @@ -53,7 +53,13 @@ extension DesignLoader { // Reservation of identities } // Reservation Phase 2: Create those IDs we do not have - let rawFrameIDs = resolution.rawFrames.map { $0.id } + let rawFrameIDs: [ForeignEntityID?] + if options == .collectOrphans && resolution.rawFrames.isEmpty { + rawFrameIDs = [nil] // Request one new ID for the orphan's frame + } + else { + rawFrameIDs = resolution.rawFrames.map { $0.id } + } let rawSnapshotIDs = resolution.rawSnapshots.map { $0.snapshotID } let rawObjectIDs = resolution.rawSnapshots.map { $0.objectID } diff --git a/Sources/PoieticCore/Foreign/DesignLoader.swift b/Sources/PoieticCore/Foreign/DesignLoader.swift index 9e0e3120..53b07b82 100644 --- a/Sources/PoieticCore/Foreign/DesignLoader.swift +++ b/Sources/PoieticCore/Foreign/DesignLoader.swift @@ -123,6 +123,12 @@ public class DesignLoader { /// When snapshot ID is a string, use it as a name attribute, if not present. public static let useIDAsNameAttribute = Options(rawValue: 1 << 0) + /// Collect orphaned snapshots into a single frame. The orphaned snapshots must satisfy + /// structural integrity requirements. + /// + /// This option is used only when loading whole design. Has no effect on loading into a + /// frame. + public static let collectOrphans = Options(rawValue: 2 << 0) } /// Create a design loader for design that conform to given metamodel. @@ -148,7 +154,6 @@ public class DesignLoader { // The loader uses something similar to a pipeline pattern. // Stages are separate steps that use only relevant processing context and produce value for the next step. let design: Design = Design(metamodel: metamodel) - let validationResolution = try validate( rawDesign: rawDesign, identityManager: design.identityManager @@ -158,17 +163,26 @@ public class DesignLoader { resolution: validationResolution, identityStrategy: .requireProvided ) - + let partialSnapshotResolution = try resolveObjectSnapshots( resolution: validationResolution, identities: identityResolution, ) - let frameResolution = try resolveFrames( - resolution: validationResolution, - identities: identityResolution, - ) - + + let frameResolution: FrameResolution + if options == .collectOrphans && validationResolution.rawFrames.count == 0 { + frameResolution = try resolveOrphansFrame( + resolution: validationResolution, + identities: identityResolution, + ) + } + else { + frameResolution = try resolveFrames( + resolution: validationResolution, + identities: identityResolution, + ) + } let hierarchicalSnapshots = try resolveHierarchy( frameResolution: frameResolution, snapshotResolution: partialSnapshotResolution @@ -180,14 +194,13 @@ public class DesignLoader { for snapshot in snapshots { snapshotMap[snapshot.snapshotID] = snapshot } - + try insertFrames( resolvedFrames: frameResolution.frames, - snapshots: snapshots, snapshotMap: snapshotMap, into: design ) - + // FIXME: [IMPORTANT] We need guarantee that the raw design corresponds to the identity reservations let namedReferences = try resolveNamedReferences( rawDesign: rawDesign, @@ -313,7 +326,7 @@ public class DesignLoader { do { // TODO: [WIP] Is this needed? The caller is validating the frame anyway before accept(). - try frame.validateStructure() + try StructuralValidator.validate(snapshots: snapshots, in: frame) } catch { throw .item(.frames, 0, .brokenStructuralIntegrity(error)) @@ -348,6 +361,20 @@ public class DesignLoader { return FrameResolution(frames: resolvedFrames) } + + internal func resolveOrphansFrame(resolution: ValidationResolution, + identities: IdentityResolution) + throws (DesignLoaderError) -> FrameResolution + { + precondition(resolution.rawFrames.count == 0) // We have no frames requested ... + precondition(identities.frameIDs.count == 1) // ... yet we reserved one ID for us here. + + let resolved = ResolvedFrame(frameID: identities.frameIDs[0], + snapshots: identities.snapshotIDs) + + return FrameResolution(frames: [resolved]) + } + /// - Returns: List of indices of object snapshots in the list of all snapshots. /// @@ -493,7 +520,6 @@ public class DesignLoader { } internal func insertFrames(resolvedFrames: [ResolvedFrame], - snapshots: [ObjectSnapshot], snapshotMap: [ObjectSnapshotID:ObjectSnapshot], into design: Design) throws (DesignLoaderError) @@ -508,8 +534,9 @@ public class DesignLoader { id: resolvedFrame.frameID, snapshots: frameSnapshots) + let debugIDs = frameSnapshots.map { $0.snapshotID } do { - try frame.validateStructure() + try StructuralValidator.validate(snapshots: frameSnapshots, in: frame) } catch { throw .item(.frames, i, .brokenStructuralIntegrity(error)) @@ -656,7 +683,12 @@ public class DesignLoader { let ids: [FrameID] = list.typedIDs() design.redoList = ids } - if let ref = namedReferences.systemReferences["current_frame"] { + if options == .collectOrphans && design.frames.count == 1, + let onlyFrameID = design.frames.first?.id + { + design.currentFrameID = onlyFrameID + } + else if let ref = namedReferences.systemReferences["current_frame"] { guard ref.type == .frame else { throw .design(.namedReferenceTypeMismatch("current_frame")) } diff --git a/Sources/PoieticCore/Model/Domains/SimulationDomain.swift b/Sources/PoieticCore/Model/Domains/SimulationDomain.swift index 6a408975..d0ca8ec5 100644 --- a/Sources/PoieticCore/Model/Domains/SimulationDomain.swift +++ b/Sources/PoieticCore/Model/Domains/SimulationDomain.swift @@ -7,6 +7,7 @@ // Simulation related traits +// TODO: Add SimulationObject trait // TODO: Add TimeSeries trait // TODO: Add NumericValue trait diff --git a/Sources/PoieticCore/Model/Metamodel.swift b/Sources/PoieticCore/Model/Metamodel.swift index 73d5bb53..5c23765b 100644 --- a/Sources/PoieticCore/Model/Metamodel.swift +++ b/Sources/PoieticCore/Model/Metamodel.swift @@ -76,7 +76,7 @@ public final class Metamodel: Sendable { self.edgeRules = edgeRules self.constraints = constraints } - + /// Create a metamodel by merging multiple metamodels. /// /// If traits, constraints and types have duplicate name, then the later @@ -127,7 +127,7 @@ public final class Metamodel: Sendable { self.constraints = constraints self.edgeRules = edgeRules } - + /// Selection of node object types. /// public var nodeTypes: [ObjectType] { @@ -173,6 +173,12 @@ public final class Metamodel: Sendable { public func objectType(name: String) -> ObjectType? { return types.first { $0.name == name} } + public func hasType(name: String) -> Bool { + return types.contains { $0.name == name} + } + public func hasType(_ type: ObjectType) -> Bool { + return types.contains { $0 === type} + } public func trait(name: String) -> Trait? { return traits.first { $0.name == name} } diff --git a/Sources/PoieticCore/Persistence/DesignStore.swift b/Sources/PoieticCore/Persistence/DesignStore.swift index cea3edb9..bde9673e 100644 --- a/Sources/PoieticCore/Persistence/DesignStore.swift +++ b/Sources/PoieticCore/Persistence/DesignStore.swift @@ -96,8 +96,8 @@ public class DesignStore { catch { throw .readingError(error) } - - let loader = DesignLoader(metamodel: metamodel) + + let loader = DesignLoader(metamodel: metamodel, options: .collectOrphans) do { design = try loader.load(rawDesign) } diff --git a/Sources/PoieticCore/Runtime/AugmentedFrame.swift b/Sources/PoieticCore/Runtime/AugmentedFrame.swift new file mode 100644 index 00000000..aa1205a4 --- /dev/null +++ b/Sources/PoieticCore/Runtime/AugmentedFrame.swift @@ -0,0 +1,266 @@ +// +// AugmentedFrame.swift +// poietic-core +// +// Created by Stefan Urbanek on 29/10/2024. +// + +/// Augmented frame is a frame wrapper that adds derived, aggregate or other temporary information +/// to the design frame in form of components. +/// +/// Runtime enriches a regular frame with derived information and a list of issues. The derived +/// information is stored in form of components, which are typically created by systems. +/// (see ``System``). Example of a component might be visual representation of a node, or +/// "inflows-outflows" of a node based on surrounding edges. +/// +/// Information in the augmented frame is not persisted with the design. +/// +/// ## Usage +/// +/// ```swift +/// let frame: DesignFrame // Assuming this is given +/// let augmented = AugmentedFrame(validatedFrame) +/// +/// // Use as a regular frame +/// let stocks = augmented.filter(type: .Stock) +/// +/// // Access components +/// let expr = augmented.component(UnboundExpression.self, for: objectID) +/// +/// // Set components (typically done by systems) +/// augmented.setComponent(expr, for: objectID) +/// ``` +/// +public final class AugmentedFrame: Frame { + // TODO: Turn this into a World that wraps a replace-able DesignFrame as a source of truth + + /// The validated frame which the runtime context is associated with. + public let wrapped: DesignFrame + + /// Components of particular objects. + /// + private var components: [RuntimeEntityID: ComponentSet] + + // TODO: [IMPORTANT] Make issues a component, to unify the interface. + // TODO: Make a special error protocol confirming to custom str convertible and having property 'hint:String' + /// User-facing issues collected during frame processing. + /// + /// These are non-fatal issues that indicate problems with user data, + /// not programming errors. The issues are intended to be displayed to the user, preferably + /// within a context of the object which the issue is associated with. + /// + /// Issue list is analogous to a list of syntax errors that were encountered during a + /// programming language source code compilation. + /// + public private(set) var issues: [ObjectID: [Issue]] + + /// Create a runtime frame wrapping a validated frame. + /// + /// - Parameter validated: The validated frame to wrap + /// + public init(_ validated: DesignFrame) { + self.wrapped = validated + self.components = [:] + self.issues = [:] + } + + // MARK: - Frame Protocol + // Delegate all to wrapped validated frame. + + @inlinable public var design: Design { wrapped.design } + @inlinable public var id: FrameID { wrapped.id } + @inlinable public var snapshots: [ObjectSnapshot] { wrapped.snapshots } + @inlinable public var objectIDs: [ObjectID] { wrapped.objectIDs } + + public var isEmpty: Bool { wrapped.isEmpty && components.isEmpty } + + @inlinable public func contains(_ id: ObjectID) -> Bool { + wrapped.contains(id) + } + + @inlinable public func object(_ id: ObjectID) -> ObjectSnapshot? { + wrapped.object(id) + } + + @inlinable public var nodeKeys: [ObjectID] { wrapped.nodeKeys } + @inlinable public var edgeKeys: [ObjectID] { wrapped.edgeKeys } + @inlinable public var edges: [EdgeObject] { wrapped.edges } + + @inlinable public func outgoing(_ origin: NodeKey) -> [Edge] { + wrapped.outgoing(origin) + } + + @inlinable public func incoming(_ target: NodeKey) -> [Edge] { + wrapped.incoming(target) + } + + // MARK: - Object Components + + /// Get a component for a runtime object + /// + /// - Parameters: + /// - runtimeID: Runtime ID of an object or an ephemeral entity. + /// - Returns: The component if it exists, otherwise nil + /// + public func component(for runtimeID: RuntimeEntityID) -> T? { + components[runtimeID]?[T.self] + } + + /// Get a component for a runtime object + /// + /// - Parameters: + /// - objectID: The object ID + /// - Returns: The component if it exists, otherwise nil + /// + public func component(for objectID: ObjectID) -> T? { + components[.object(objectID)]?[T.self] + } + + /// Set a component for a specific object + /// + /// If a component of the same type already exists for this object, + /// it will be replaced. + /// + /// - Parameters: + /// - component: The component to set + /// - objectID: The object ID + /// + public func setComponent(_ component: T, for runtimeID: RuntimeEntityID) { + // TODO: Check whether the object exists + components[runtimeID, default: ComponentSet()].set(component) + } + public func setComponent(_ component: T, for objectID: ObjectID) { + setComponent(component, for: .object(objectID)) + } + + /// Check if an object has a specific component type + /// + /// - Parameters: + /// - type: The component type to check + /// - objectID: The object ID + /// - Returns: True if the object has the component, otherwise false + /// + public func hasComponent(_ type: T.Type, for runtimeID: RuntimeEntityID) -> Bool { + components[runtimeID]?.has(type) ?? false + } + + /// Remove a component from an object + /// + /// - Parameters: + /// - type: The component type to remove + /// - objectID: The object ID + /// + public func removeComponent(_ type: T.Type, for runtimeID: RuntimeEntityID) { + // TODO: Check whether the object exists + components[runtimeID]?.remove(type) + } + public func removeComponent(_ type: T.Type, for objectID: ObjectID) { + removeComponent(type, for: .object(objectID)) + } + + // TODO: Rename to "removeForAll" + public func removeComponentForAll(_ type: T.Type) { + for id in components.keys { + components[id]?.remove(type) + } + } + + /// Get all object IDs that have a specific component type + /// + /// - Parameter type: The component type to query + /// - Returns: Array of object IDs that have this component + /// + public func objectIDs(with type: T.Type) -> [ObjectID] { + components.compactMap { runtimeID, components in + switch runtimeID { + case .object(let objectID): + components.has(type) ? objectID : nil + case .ephemeral(_): + nil + } + } + } + // MARK: - Filter + + /// Get a list of objects with given component. + /// + public func filter(_ componentType: T.Type) -> some Collection<(ObjectID, T)> { + components.compactMap { runtimeID, components in + switch runtimeID { + case .object(let objectID): + guard let comp: T = components[T.self] else { + return nil + } + return (objectID, comp) + case .ephemeral(_): + return nil + } + } + } + + public func first(withComponent componentType: T.Type) + -> (id: RuntimeEntityID, component: T)? + { + let result = components.first { id, components in components.has(componentType) } + if let result { + return (id: result.key, component: result.value[componentType]!) + } + else { + return nil + } + + } + + // TODO: Is this a good name? + public func runtimeFilter(_ componentType: T.Type) -> some Collection<(RuntimeEntityID, T)> { + components.compactMap { runtimeID, components in + guard let comp: T = components[T.self] else { + return nil + } + return (runtimeID, comp) + } + } + // MARK: - Issues + + /// Flag indicating whether any issues were collected + public var hasIssues: Bool { !issues.isEmpty } + + public func objectHasIssues(_ objectID: ObjectID) -> Bool { + guard let issues = self.issues[objectID] else { return false } + return issues.isEmpty + } + + public func objectIssues(_ objectID: ObjectID) -> [Issue]? { + guard let issues = self.issues[objectID], !issues.isEmpty else { return nil } + return issues + + } + + /// Append a user-facing issue for a specific object + /// + /// Issues are non-fatal problems with user data. Systems should append + /// issues here rather than throwing errors, allowing processing to continue + /// and collect multiple issues. + /// + /// - Parameters: + /// - issue: The error/issue to append + /// - objectID: The object ID associated with the issue + /// + public func appendIssue(_ issue: Issue, for objectID: ObjectID) { + issues[objectID, default: []].append(issue) + } +} + +// Testing convenience methods +extension AugmentedFrame { + func objectHasError(_ objectID: ObjectID, error: T) -> Bool { + guard let issues = objectIssues(objectID) else { return false } + + for issue in issues { + if let objectError = issue.error as? T, objectError == error { + return true + } + } + return false + } +} diff --git a/Sources/PoieticCore/Runtime/EphemeralObject.swift b/Sources/PoieticCore/Runtime/EphemeralObject.swift new file mode 100644 index 00000000..c5452521 --- /dev/null +++ b/Sources/PoieticCore/Runtime/EphemeralObject.swift @@ -0,0 +1,60 @@ +// +// EphemeralObject.swift +// poietic-core +// +// Created by Stefan Urbanek on 10/11/2025. +// + +public struct EphemeralID: + Hashable, + RawRepresentable, + CustomStringConvertible, + ExpressibleByIntegerLiteral, + Sendable +{ + public init(integerLiteral value: UInt64) { + self.rawValue = value + } + + public typealias IntegerLiteralType = UInt64 + + public var rawValue: UInt64 + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + public var description: String { String(rawValue) } +} + +/// Unique identity used during runtime for storing components in ``AugmentedFrame``. +/// +public enum RuntimeEntityID: + Hashable, + CustomStringConvertible, + Sendable +{ + // Runtime entity backed by a design object + case object(ObjectID) + // Ephemeral runtime entity that is not backed by a concrete object. + case ephemeral(EphemeralID) + + // ID of an ephemeral entity that represents the whole frame. + public static let Frame = RuntimeEntityID.ephemeral(0) + // NOTE: Update this constant based on the known list of reserved values + internal static let FirstEphemeralIDValue: UInt64 = 10 + + public var objectID: ObjectID? { + switch self { + case .object(let id): id + case .ephemeral(_): nil + } + } + + public var description: String { + switch self { + case .object(let id): "\(id)" + case .ephemeral(let id): "e\(id)" + } + } +} diff --git a/Sources/PoieticCore/Runtime/RuntimeFrame.swift b/Sources/PoieticCore/Runtime/RuntimeFrame.swift deleted file mode 100644 index 73edffdd..00000000 --- a/Sources/PoieticCore/Runtime/RuntimeFrame.swift +++ /dev/null @@ -1,212 +0,0 @@ -// -// RuntimeFrame.swift -// poietic-core -// -// Created by Stefan Urbanek on 29/10/2024. -// - -/// Runtime frame is a frame wrapper that adds runtime information in form of components. -/// -/// Runtime enriches a regular frame with derived information and a list of issues. The derived -/// information is stored in form of components, which are typically created by systems. -/// (see ``System``). Example of a component might be visual representation of a node, or -/// "inflows-outflows" of a node based on surrounding edges. -/// -/// Information in the runtime frame is not persisted with the design. -/// -/// -/// ## Usage -/// -/// ```swift -/// let validatedFrame = try design.validate(design.currentFrame!) -/// let runtimeFrame = RuntimeFrame(validatedFrame) -/// -/// // Use as a regular frame -/// let stocks = runtimeFrame.filter(type: .Stock) -/// -/// // Access components -/// let expr = runtimeFrame.component(UnboundExpression.self, for: objectID) -/// -/// // Set components (typically done by systems) -/// runtimeFrame.setComponent(expr, for: objectID) -/// ``` -/// -public final class RuntimeFrame: Frame { - // TODO: We can "wrap the unwrapped" here, we trust the validated frame, so we can refer directly to design frame and remove one level of indirection. - /// The validated frame which the runtime context is associated with. - public let wrapped: ValidatedFrame - - /// Components of particular objects. - /// - private var objectComponents: [ObjectID: ComponentSet] - - /// Components related to the frame as a whole. - /// - private var frameComponents: ComponentSet - - // TODO: Make a special error protocol confirming to custom str convertible and having property 'hint:String' - /// User-facing issues collected during frame processing. - /// - /// These are non-fatal issues that indicate problems with user data, - /// not programming errors. The issues are intended to be displayed to the user, preferably - /// within a context of the object which the issue is associated with. - /// - /// Issue list is analogous to a list of syntax errors that were encountered during a - /// programming language source code compilation. - /// - public private(set) var issues: [ObjectID: [Error]] - - /// Create a runtime frame wrapping a validated frame. - /// - /// - Parameter validated: The validated frame to wrap - /// - public init(_ validated: ValidatedFrame) { - self.wrapped = validated - self.objectComponents = [:] - self.frameComponents = ComponentSet() - self.issues = [:] - } - - // MARK: - Frame Protocol - // Delegate all to wrapped validated frame. - - @inlinable public var design: Design { wrapped.design } - @inlinable public var id: FrameID { wrapped.id } - @inlinable public var snapshots: [ObjectSnapshot] { wrapped.snapshots } - @inlinable public var objectIDs: [ObjectID] { wrapped.objectIDs } - - @inlinable public func contains(_ id: ObjectID) -> Bool { - wrapped.contains(id) - } - - @inlinable public func object(_ id: ObjectID) -> ObjectSnapshot? { - wrapped.object(id) - } - - @inlinable public var nodeKeys: [ObjectID] { wrapped.nodeKeys } - @inlinable public var edgeKeys: [ObjectID] { wrapped.edgeKeys } - @inlinable public var edges: [EdgeObject] { wrapped.edges } - - @inlinable public func outgoing(_ origin: NodeKey) -> [Edge] { - wrapped.outgoing(origin) - } - - @inlinable public func incoming(_ target: NodeKey) -> [Edge] { - wrapped.incoming(target) - } - - // MARK: - Object Components - - /// Get a component for a specific object - /// - /// - Parameters: - /// - objectID: The object ID - /// - Returns: The component if it exists, otherwise nil - /// - public func component(for objectID: ObjectID) -> T? { - objectComponents[objectID]?[T.self] - } - - /// Set a component for a specific object - /// - /// If a component of the same type already exists for this object, - /// it will be replaced. - /// - /// - Parameters: - /// - component: The component to set - /// - objectID: The object ID - /// - public func setComponent(_ component: T, for objectID: ObjectID) { - objectComponents[objectID, default: ComponentSet()].set(component) - } - - /// Check if an object has a specific component type - /// - /// - Parameters: - /// - type: The component type to check - /// - objectID: The object ID - /// - Returns: True if the object has the component, otherwise false - /// - public func hasComponent(_ type: T.Type, for objectID: ObjectID) -> Bool { - objectComponents[objectID]?.has(type) ?? false - } - - /// Remove a component from an object - /// - /// - Parameters: - /// - type: The component type to remove - /// - objectID: The object ID - /// - public func removeComponent(_ type: T.Type, for objectID: ObjectID) { - objectComponents[objectID]?.remove(type) - } - - /// Get all object IDs that have a specific component type - /// - /// - Parameter type: The component type to query - /// - Returns: Array of object IDs that have this component - /// - public func objectIDs(with type: T.Type) -> [ObjectID] { - objectComponents.compactMap { objectID, components in - components.has(type) ? objectID : nil - } - } - - // MARK: - Frame Components - - /// Get a frame-level metadata component - /// - /// Frame-level components store metadata that applies to the entire frame, - /// such as computation order or dependency graphs. - /// - /// - Parameter type: The component type to retrieve - /// - Returns: The component if it exists, otherwise nil - /// - public func frameComponent(_ type: T.Type) -> T? { - frameComponents[type] - } - - /// Set a frame-level metadata component - /// - /// - Parameter component: The component to set - /// - public func setFrameComponent(_ component: T) { - frameComponents.set(component) - } - - /// Check if a frame-level component exists - /// - /// - Parameter type: The component type to check - /// - Returns: True if the component exists, otherwise false - /// - public func hasFrameComponent(_ type: T.Type) -> Bool { - frameComponents.has(type) - } - - /// Remove a frame-level component - /// - /// - Parameter type: The component type to remove - /// - public func removeFrameComponent(_ type: T.Type) { - frameComponents.remove(type) - } - - // MARK: - Issues - - /// Flag indicating whether any issues were collected - public var hasIssues: Bool { !issues.isEmpty } - - /// Append a user-facing issue for a specific object - /// - /// Issues are non-fatal problems with user data. Systems should append - /// issues here rather than throwing errors, allowing processing to continue - /// and collect multiple issues. - /// - /// - Parameters: - /// - issue: The error/issue to append - /// - objectID: The object ID associated with the issue - /// - public func appendIssue(_ issue: Error, for objectID: ObjectID) { - issues[objectID, default: []].append(issue) - } -} diff --git a/Sources/PoieticCore/Runtime/System.swift b/Sources/PoieticCore/Runtime/System.swift index fd46eba5..b97e5ada 100644 --- a/Sources/PoieticCore/Runtime/System.swift +++ b/Sources/PoieticCore/Runtime/System.swift @@ -10,10 +10,16 @@ /// Systems can specify execution order constraints relative to other systems. /// public enum SystemDependency { - /// This system must run before the specified system + /// This system must run after the specified system and the other system must exist in the + /// system group. + case requires(any System.Type) + + /// This system must run before the specified system, if the other system is present + /// in a system group. case before(any System.Type) - /// This system must run after the specified system + /// This system must run after the specified system, if the other system is present + /// in a system group. case after(any System.Type) } @@ -56,7 +62,7 @@ public protocol System { /// specify ordering constraints. /// static var dependencies: [SystemDependency] { get } - + /// Execute the system that reads and updates a runtime frame. /// /// Systems can: @@ -65,12 +71,20 @@ public protocol System { /// /// - Parameter frame: The runtime frame to process /// - func update(_ frame: RuntimeFrame) throws (InternalSystemError) + func update(_ frame: AugmentedFrame) throws (InternalSystemError) + + // TODO: Pass Design or application context in the future. Not needed now. + /// Initialise the system. + init() } extension System { /// Default to no dependencies public static var dependencies: [SystemDependency] { [] } + public init() { + self.init() + // Do nothing + } } /// Error thrown by systems that has not been caused by the user, but that is recoverable in @@ -82,10 +96,55 @@ extension System { /// Preferably, it might be suggested to the user that developers are to be contacted with this /// error. /// -public enum InternalSystemError: Error { - case compilationError -// case invalidValue(ObjectID, String, Variant?) -// case invalidStructure(ObjectID) -// case objectNotFound(ObjectID) -} +public struct InternalSystemError: Error, Equatable, CustomStringConvertible { + public enum Context: Sendable, Equatable, CustomStringConvertible { + case none + case frame + case frameComponent(String) + + case object(ObjectID) + case component(ObjectID, String) + case attribute(ObjectID, String) + + public var description: String { + switch self { + case .none: "no context" + case let .object(id): "object ID \(id)" + case let .attribute(id, name): "attribute '\(name)' in ID \(id)" + case let .component(id, name): "component \(name) in ID \(id)" + case .frame: "frame" + case let .frameComponent(name): "frame component \(name)" + } + } + + public init(frameComponent: some Component) { + let typeName = String(describing: type(of: frameComponent)) + self = .frameComponent(typeName) + } + public init(id: ObjectID, component: some Component) { + let typeName = String(describing: type(of: component)) + self = .component(id, typeName) + } + } + + public let system: String + public let message: String + public let context: Context + + public var description: String { + "Internal System Error (\(system)): \(message). Context: \(context)" + } + public init(_ system: String, message: String, context: Context = .none) { + self.system = system + self.message = message + self.context = context + } + + public init(_ system: some System, message: String, context: Context = .none) { + let typeName = String(describing: type(of: system)) + self.system = typeName + self.message = message + self.context = context + } +} diff --git a/Sources/PoieticCore/Runtime/SystemGroup.swift b/Sources/PoieticCore/Runtime/SystemGroup.swift new file mode 100644 index 00000000..faea84e6 --- /dev/null +++ b/Sources/PoieticCore/Runtime/SystemGroup.swift @@ -0,0 +1,211 @@ +// +// SystemGroup.swift +// poietic-core +// +// Created by Stefan Urbanek on 29/10/2024. +// + +/* + Development Notes: + + - This is an early sketch of Systems and SystemGroups + - TODO: Have a central per-app or per-design system registry + - TODO: ^^ see poietic-godot and DesignController and RuntimePhase for seed of the above TODO + - TODO: Remove mutability of System-group + */ + +/// System group is a collection of systems that run in order of their dependency. +/// +/// ## Use +/// +/// Typically there is one group per problem domain and even per application. For example, +/// a Stock and Flow simulation application would have just one system scheduler with systems +/// for expression parsing, flow dependency graph and computational model creation. +/// +/// ## Example +/// +/// ```swift +/// let systems = SystemGroup() +/// +/// systems.register(ExpressionParserSystem()) +/// systems.register(ParametereDependecySystem()) +/// systems.register(StockFlowAnalysisSystem()) +/// +/// let runtimeFrame = RuntimeFrame(validatedFrame) +/// try systems.update(runtimeFrame) +/// ``` +/// +/// - Note: The concept of Systems in this library is for modelling and separation of concerns, +/// not for performance reasons. +/// +public final class SystemGroup { + // TODO: Make immutable through public interface + /// Registered systems indexed by type name + private var systems: [ObjectIdentifier: System.Type] + + /// Computed execution order + private var _executionOrder: [System.Type] + private var _instances: [any System] + + + convenience public init(_ systems: System.Type ...) { + self.init(systems) + } + + public init(_ systems: [System.Type]) { + self.systems = [:] + self._executionOrder = [] + self._instances = [] + self.register(systems) + } + + /// Register a system + /// + /// After registration, execution order is recomputed based on all + /// registered systems and their dependencies. + /// + /// There can be only one system of given type. When registering a system of already registered + /// system type, the old one will be discarded and the new one will be used. + /// + /// - Parameter system: The system to register + /// - Precondition: The system dependencies must not contain a cycle and references must exist. + /// + public func register(_ system: System.Type) { + let id = ObjectIdentifier(system) + + systems[id] = system + _executionOrder = Self.dependencyOrder(Array(systems.values)) + } + + /// Register multiple systems at once. + /// + /// - SeeAlso: ``register()`` + /// + public func register(_ systems: [System.Type]) { + for system in systems { + let id = ObjectIdentifier(system) + self.systems[id] = system + } + _executionOrder = Self.dependencyOrder(Array(self.systems.values)) + } + + + /// Execute all systems in dependency order + /// + /// Systems are executed sequentially in topological order based on + /// their declared dependencies. + /// + /// - Parameter frame: The runtime frame to process + /// - Throws: Errors from system execution + /// + public func update(_ frame: AugmentedFrame) throws (InternalSystemError) { + if _instances.isEmpty { + self.instantiate() + } + for system in _instances { + try system.update(frame) + } + } + + public func debugUpdate(_ frame: AugmentedFrame, + before: ((any System, AugmentedFrame) -> Void), + after: ((any System, AugmentedFrame) -> Void)) + throws (InternalSystemError) { + if _instances.isEmpty { + self.instantiate() + } + for system in _instances { + before(system, frame) + try system.update(frame) + after(system, frame) + } + } + + + public func instantiate() { + // TODO: Add frame or some initialisation context + for systemType in _executionOrder { + let system = systemType.init() + _instances.append(system) + } + } + /// Get names of the systems in the the computed execution order + /// + public func debugDependencyOrder() -> [String] { + _executionOrder.map { String(describing: type(of: $0)) } + } + + /// Compute execution order based on system dependencies + /// + /// Uses topological sort to order systems respecting `.before()` and + /// `.after()` constraints. + /// + /// - Parameters: + /// - systems: List of systems to be ordered. + /// - strict: Flag whether dependencies are strictly required. + /// + /// - Returns: Sorted array of systems. + /// - Precondition: There must be no dependency cycle within systems. + /// - Precondition: If `strict` is `true` then all systems listed in dependencies must be + /// present in the list. If `strict` is `false` then systems not present are ignored. + /// + public static func dependencyOrder(_ systems: [System.Type]) -> [System.Type] { + let systemMap = systems.reduce(into: [ObjectIdentifier: System.Type]()) { + (result, system) in + result[ObjectIdentifier(system)] = system + } + + var edges: [(origin: ObjectIdentifier, target: ObjectIdentifier)] = [] + var maybeIndependent: Set = [] + + // First pass: validate hard dependencies and collect soft ones + for system in systems { + let systemID = ObjectIdentifier(system) + guard !system.dependencies.isEmpty else { + maybeIndependent.insert(systemID) + continue + } + + for dependency in system.dependencies { + let origin: ObjectIdentifier + let target: ObjectIdentifier + let otherID: ObjectIdentifier + let required: Bool + switch dependency { + case let .requires(id): + otherID = ObjectIdentifier(id) + (origin, target) = (systemID, otherID) + required = true + case let .before(id): + otherID = ObjectIdentifier(id) + (origin, target) = (systemID, otherID) + required = false + case let .after(id): + otherID = ObjectIdentifier(id) + (origin, target) = (otherID, systemID) + required = false + } + + guard systemMap[otherID] != nil else { + if required { + fatalError("System \(system) requires missing system: \(otherID)") + } + else { + maybeIndependent.insert(systemID) + } + continue + } + + edges.append((origin: origin, target: target)) + } + } + + guard let sorted = topologicalSort(edges) else { + fatalError("Circular dependency detected in systems") + } + + let independent = maybeIndependent.filter { !sorted.contains($0) } + + return (independent + sorted).compactMap { systemMap[$0] } + } +} diff --git a/Sources/PoieticCore/Runtime/SystemScheduler.swift b/Sources/PoieticCore/Runtime/SystemScheduler.swift deleted file mode 100644 index 8e95c7a0..00000000 --- a/Sources/PoieticCore/Runtime/SystemScheduler.swift +++ /dev/null @@ -1,148 +0,0 @@ -// -// SystemScheduler.swift -// poietic-core -// -// Created by Stefan Urbanek on 29/10/2024. -// - -/* - Development Notes: - - - A system processes all entities that match its component query, and ignores those that don't. - - - */ - -/// System scheduler is responsible for system registration, dependency ordering and running. -/// -/// ## Use -/// -/// Typically there is one scheduler per problem domain and even per application. For example, -/// a Stock and Flow simulation application would have just one system scheduler with systems -/// for expression parsing, flow dependency graph and computational model creation. -/// -/// ## Example -/// -/// ```swift -/// let scheduler = SystemScheduler() -/// -/// scheduler.register(ExpressionParserSystem()) -/// scheduler.register(ParametereDependecySystem()) -/// scheduler.register(StockFlowAnalysisSystem()) -/// -/// let runtimeFrame = RuntimeFrame(validatedFrame) -/// try scheduler.execute(runtimeFrame) -/// ``` -/// -/// - Note: The concept of Systems in this library is for modelling and separation of concerns, -/// not for performance reasons. -/// -public final class SystemScheduler { - /// Registered systems indexed by type name - private var systems: [ObjectIdentifier: any System] = [:] - - /// Computed execution order - private var _executionOrder: [any System] = [] - - /// Register a system - /// - /// After registration, execution order is recomputed based on all - /// registered systems and their dependencies. - /// - /// There can be only one system of given type. When registering a system of already registered - /// system type, the old one will be discarded and the new one will be used. - /// - /// - Parameter system: The system to register - /// - Precondition: The system dependencies must not contain a cycle and references must exist. - /// - public func register(_ system: any System) { - let id = type(of: system)._systemTypeIdentifier - - systems[id] = system - _executionOrder = Self.dependencyOrder(Array(systems.values)) - } - - /// Register multiple systems at once. - /// - /// - SeeAlso: ``register()`` - /// - public func register(_ systems: [any System]) { - for system in systems { - let id = type(of: system)._systemTypeIdentifier - self.systems[id] = system - } - _executionOrder = Self.dependencyOrder(Array(self.systems.values)) - } - - - /// Execute all systems in dependency order - /// - /// Systems are executed sequentially in topological order based on - /// their declared dependencies. - /// - /// - Parameter frame: The runtime frame to process - /// - Throws: Errors from system execution - /// - public func execute(_ frame: RuntimeFrame) throws (InternalSystemError) { - for system in _executionOrder { - try system.update(frame) - } - } - - /// Get names of the systems in the the computed execution order - /// - public func debugDependencyOrder() -> [String] { - _executionOrder.map { String(describing: type(of: $0)) } - } - - /// Compute execution order based on system dependencies - /// - /// Uses topological sort to order systems respecting `.before()` and - /// `.after()` constraints. - /// - /// - Returns: Sorted array of systems - /// - Precondition: Systems in the dependency references must be known and there must be no - /// cycle. - /// - public static func dependencyOrder(_ systems: [any System]) -> [any System] - { - var systemMap: [ObjectIdentifier: any System] = [:] - var edges: [(ObjectIdentifier, ObjectIdentifier)] = [] - - for system in systems { - let id = type(of: system)._systemTypeIdentifier - systemMap[id] = system - } - for system in systems { - let systemID = type(of: system)._systemTypeIdentifier - for dep in type(of: system).dependencies { - switch dep { - case .after(let other): - let otherID = other._systemTypeIdentifier - precondition(systemMap[otherID] != nil, - "Error sorting system \(system): Missing system: \(other)") - edges.append((origin: systemID, target: otherID)) - case .before(let other): - let otherID = other._systemTypeIdentifier - precondition(systemMap[otherID] != nil, - "Error sorting system \(system): Missing system: \(other)") - edges.append((origin: otherID, target: systemID)) - } - } - } - - guard let sorted = topologicalSort(edges) else { - fatalError("Circular dependency in Systems") - } - - let result = sorted.compactMap { systemMap[$0] } - - return result - } -} - -extension System { - internal static var _systemTypeIdentifier: ObjectIdentifier { - return ObjectIdentifier(self) - } -} diff --git a/Sources/PoieticCore/System/ExpressionParserSystem.swift b/Sources/PoieticCore/System/ExpressionParserSystem.swift new file mode 100644 index 00000000..5b0e9b53 --- /dev/null +++ b/Sources/PoieticCore/System/ExpressionParserSystem.swift @@ -0,0 +1,78 @@ +// +// ExpressionParserSystem.swift +// poietic-core +// +// Created by Stefan Urbanek on 02/11/2025. +// + +/// Parsed arithmetic expression (frame-independent) +public struct ParsedExpressionComponent: Component { + // Note: We do not need to store error here, we store it in the list of all issues. It is not + // relevant to be in the component. + public enum Content { + case expression(UnboundExpression) + case error + } + public let content: Content + public let variables: Set + + public var isError: Bool { + switch content { + case .expression(_): false + case .error: true + } + } + public var expression: UnboundExpression? { + switch content { + case .expression(let expr): expr + case .error: nil + } + } +} + +/// System that parses formulas into unbound expressions. +/// +/// - **Input:** Objects with trait ``Trait/Formula``. +/// - **Output:** ``ParsedExpression`` component. +/// - **Forgiveness:** Objects with missing or invalid `formula` attribute will be ignored. +/// +public struct ExpressionParserSystem: System { + public init() {} + public func update(_ frame: AugmentedFrame) { + for object in frame.filter(trait: .Formula) { + guard let formula: String = object["formula"] else { continue } + + let expr: UnboundExpression + let component: ParsedExpressionComponent + + do { + let parser = ExpressionParser(string: formula) + expr = try parser.parse() + component = ParsedExpressionComponent( + content: .expression(expr), + variables: Set(expr.allVariables) + ) + } + catch { + let issue = Issue( + identifier: "syntax_error", + severity: .error, + system: self, + error: error, + details: [ + "attribute": "formula", + "underlying_error": Variant(error.description), + ] + ) + + frame.appendIssue(issue, for: object.objectID) + component = ParsedExpressionComponent( + content: .error, + variables: Set() + ) + } + + frame.setComponent(component, for: .object(object.objectID)) + } + } +} diff --git a/Tests/PoieticCoreTests/Common.swift b/Tests/PoieticCoreTests/Common.swift index e94fe9dc..a8059861 100644 --- a/Tests/PoieticCoreTests/Common.swift +++ b/Tests/PoieticCoreTests/Common.swift @@ -33,13 +33,13 @@ let TestTypeWithDefault = ObjectType(name: "TestWithDefault", let TestTraitNoDefault = Trait( name: "Test", attributes: [ - Attribute("text", type: .string) + Attribute("text", type: .string, optional: false) ] ) let TestTraitWithDefault = Trait( name: "Test", attributes: [ - Attribute("text", type: .string, default: "default") + Attribute("text", type: .string, default: "default", optional: false) ] ) @@ -119,6 +119,7 @@ public let TestMetamodel = Metamodel( TestEdgeType, TestTypeNoDefault, TestTypeWithDefault, + TestOrderType, ObjectType.Unstructured, ObjectType.Stock, @@ -130,6 +131,7 @@ public let TestMetamodel = Metamodel( ], edgeRules: [ EdgeRule(type: .Arrow), + EdgeRule(type: TestEdgeType), EdgeRule(type: .Flow, origin: IsTypePredicate(.FlowRate), outgoing: .one, diff --git a/Tests/PoieticCoreTests/Design/ConnectionRuleTests.swift b/Tests/PoieticCoreTests/Design/ConnectionRuleTests.swift index d1b9c5c3..9d7008a0 100644 --- a/Tests/PoieticCoreTests/Design/ConnectionRuleTests.swift +++ b/Tests/PoieticCoreTests/Design/ConnectionRuleTests.swift @@ -26,7 +26,7 @@ import Testing let edge = frame.createEdge(.Arrow, origin: stock.objectID, target: flow.objectID) let frozen = try design.accept(frame) - try checker.validate(edge: frozen.edge(edge.objectID), in: frozen) + try checker.validate(edge: frozen.edge(edge.objectID)!, in: frozen) } @Test func noRuleForEdge() throws { @@ -34,17 +34,19 @@ import Testing let a = frame.createNode(.Stock) let b = frame.createNode(.Stock) let e = frame.createEdge(.IllegalEdge, origin: a.objectID, target: b.objectID) - let frozen = try design.accept(frame) #expect { - try checker.validate(edge: frozen.edge(e.objectID), in: frozen) + try design.accept(frame) + // try checker.validate(edge: frozen.edge(e.objectID)!, in: frozen) } throws: { - guard let error = $0 as? EdgeRuleViolation else { + guard let error = $0 as? FrameValidationError, + case let .edgeRuleViolation(objectID, violation) = error + else { return false } - switch error { - case .edgeNotAllowed: return true + switch violation { + case .edgeNotAllowed: return objectID == e.objectID default: return false } } @@ -55,13 +57,13 @@ import Testing let a = frame.createNode(.Stock) let b = frame.createNode(.Stock) let e = frame.createEdge(.Flow, origin: a.objectID, target: b.objectID) - let frozen = try design.accept(frame) #expect { - try checker.validate(edge: frozen.edge(e.objectID), in: frozen) + try checker.validate(edge: frame.edge(e.objectID)!, in: frame) } throws: { guard let error = $0 as? EdgeRuleViolation else { + Issue.record("Invalid error thrown") return false } switch error { @@ -77,10 +79,9 @@ import Testing let b = frame.createNode(.FlowRate) let e1 = frame.createEdge(.Flow, origin: a.objectID, target: b.objectID) let e2 = frame.createEdge(.Flow, origin: a.objectID, target: b.objectID) - let frozen = try design.accept(frame) #expect { - try checker.validate(edge: frozen.edge(e1.objectID), in: frozen) + try checker.validate(edge: frame.edge(e1.objectID)!, in: frame) } throws: { guard let error = $0 as? EdgeRuleViolation else { @@ -94,7 +95,7 @@ import Testing // Both edges should have the same error #expect { - try checker.validate(edge: frozen.edge(e2.objectID), in: frozen) + try checker.validate(edge: frame.edge(e2.objectID)!, in: frame) } throws: { guard let error = $0 as? EdgeRuleViolation else { @@ -112,10 +113,9 @@ import Testing let b = frame.createNode(.Stock) let e1 = frame.createEdge(.Flow, origin: a.objectID, target: b.objectID) let e2 = frame.createEdge(.Flow, origin: a.objectID, target: b.objectID) - let frozen = try design.accept(frame) #expect { - try checker.validate(edge: frozen.edge(e1.objectID), in: frozen) + try checker.validate(edge: frame.edge(e1.objectID)!, in: frame) } throws: { guard let error = $0 as? EdgeRuleViolation else { @@ -129,7 +129,7 @@ import Testing // Both edges should have the same error #expect { - try checker.validate(edge: frozen.edge(e2.objectID), in: frozen) + try checker.validate(edge: frame.edge(e2.objectID)!, in: frame) } throws: { guard let error = $0 as? EdgeRuleViolation else { diff --git a/Tests/PoieticCoreTests/Design/DesignTests.swift b/Tests/PoieticCoreTests/Design/DesignTests.swift index d7b26c6a..a17490b7 100644 --- a/Tests/PoieticCoreTests/Design/DesignTests.swift +++ b/Tests/PoieticCoreTests/Design/DesignTests.swift @@ -150,7 +150,7 @@ import Testing structure: .orderedSet(b.objectID, [c.objectID])) try design.accept(originalFrame) - let trans = design.createFrame(deriving: design.currentFrame) + let trans = design.createFrame(deriving: originalFrame) trans.removeCascading(a.objectID) trans.removeCascading(c.objectID) @@ -181,7 +181,7 @@ import Testing let originalVersion = design.currentFrameID - let removalFrame = design.createFrame(deriving: design.currentFrame) + let removalFrame = design.createFrame(deriving: originalFrame) #expect(design.currentFrame!.contains(a.objectID)) removalFrame.removeCascading(a.objectID) @@ -206,7 +206,7 @@ import Testing #expect(design.contains(snapshot: a.snapshotID)) #expect(design.referenceCount(a.snapshotID) == 1) - let trans2 = design.createFrame(deriving: design.currentFrame) + let trans2 = design.createFrame(deriving: frame1) let frame2 = try design.accept(trans2) #expect(design.contains(snapshot: a.snapshotID)) #expect(design.referenceCount(a.snapshotID) == 2) @@ -233,11 +233,11 @@ import Testing try design.accept(design.createFrame()) let v0 = design.currentFrameID! - let frame1 = design.createFrame(deriving: design.currentFrame) + let frame1 = design.createFrame(deriving: design.currentFrame!) let a = frame1.create(TestType) try design.accept(frame1) - let frame2 = design.createFrame(deriving: design.currentFrame) + let frame2 = design.createFrame(deriving: design.currentFrame!) let b = frame2.create(TestType) try design.accept(frame2) @@ -265,11 +265,11 @@ import Testing try design.accept(design.createFrame()) let v0 = design.currentFrameID! - let frame1 = design.createFrame(deriving: design.currentFrame) + let frame1 = design.createFrame(deriving: design.currentFrame!) let a = frame1.create(TestType) try design.accept(frame1) - let frame2 = design.createFrame(deriving: design.currentFrame) + let frame2 = design.createFrame(deriving: design.currentFrame!) let b = frame2.create(TestType) try design.accept(frame2) @@ -320,7 +320,7 @@ import Testing try #require(design.currentFrameID != nil) let originalID = design.currentFrameID! - let f1 = design.createFrame(deriving: design.currentFrame) + let f1 = design.createFrame(deriving: design.currentFrame!) try design.accept(f1) #expect(design.canUndo) @@ -345,13 +345,13 @@ import Testing try design.accept(design.createFrame()) let v0 = design.currentFrameID! - let discardedFrame = design.createFrame(deriving: design.currentFrame) + let discardedFrame = design.createFrame(deriving: design.currentFrame!) let discardedObject = discardedFrame.create(TestType) try design.accept(discardedFrame) design.undo(to: v0) - let frame2 = design.createFrame(deriving: design.currentFrame) + let frame2 = design.createFrame(deriving: design.currentFrame!) let b = frame2.create(TestType) try design.accept(frame2) @@ -370,7 +370,8 @@ import Testing let constraint = Constraint(name: "test", match: AnyPredicate(), requirement: RejectAll()) - let metamodel = Metamodel(constraints: [constraint]) + let metamodel = Metamodel(merging: TestMetamodel, + Metamodel(constraints: [constraint])) let design = Design(metamodel: metamodel) let frame = design.createFrame() @@ -378,15 +379,14 @@ import Testing let b = frame.createNode(TestNodeType) #expect { - try design.validate(try design.accept(frame)) + try design.accept(frame) } throws: { let error = try #require($0 as? FrameValidationError, - "Error is not a FrameConstraintError") - let violation = try #require(error.violations.first, - "No constraint violation found") - - return error.violations.count == 1 - && violation.objects.count == 2 + "Error is not a FrameValidationError") + guard case .constraintViolation(let violation) = error else { + return false + } + return violation.objects.count == 2 && violation.objects.contains(a.objectID) && violation.objects.contains(b.objectID) } diff --git a/Tests/PoieticCoreTests/Design/TransientFrameTests.swift b/Tests/PoieticCoreTests/Design/TransientFrameTests.swift index 092fe4b8..b2b29ad7 100644 --- a/Tests/PoieticCoreTests/Design/TransientFrameTests.swift +++ b/Tests/PoieticCoreTests/Design/TransientFrameTests.swift @@ -8,7 +8,7 @@ import Testing @testable import PoieticCore -// TODO: [WIP] Test reservation release on transient frame +// TODO: [IMPORTANT] Test reservation release on transient frame @Suite struct TransientFrameTest { let design: Design @@ -36,32 +36,22 @@ import Testing #expect(b["text"] == "default") } - @Test func defaultValueTraitError() { + @Test func defaultValueTraitError() throws { + // FIXME: [REFACTORING] Move to constraint checker tests let a = frame.create(TestTypeNoDefault) let b = frame.create(TestTypeWithDefault) - #expect { - try design.validate(try design.accept(frame)) - } throws: { - guard let error = $0 as? FrameValidationError else { - Issue.record("Expected FrameValidationError") - return false - } - guard let objErrors = error.objectErrors[a.objectID] else { - Issue.record("Expected errors for object 'a'") - return false - } - - - return error.violations.count == 0 - && error.objectErrors.count == 1 - && objErrors.first == .missingTraitAttribute(TestTraitNoDefault.attributes[0], "Test") - && error.objectErrors[b.objectID] == nil - } + let checker = ConstraintChecker(frame.design.metamodel) + let result = checker.diagnose(frame) + let objErrors = try #require(result.objectErrors[a.objectID]) + #expect(result.violations.count == 0) + #expect(result.objectErrors.count == 1) + #expect(objErrors.first == .missingTraitAttribute(TestTraitNoDefault.attributes[0], "Test")) + #expect(result.objectErrors[b.objectID] == nil) } @Test func derivedStructureIsPreserved() throws { - let original = frame.create(TestNodeType) + let original = frame.create(TestNodeType, structure: .node) let originalFrame = try design.accept(frame) let derivedFrame = design.createFrame(deriving: originalFrame) @@ -89,7 +79,7 @@ import Testing let originalSnap = try #require(frame[obj.objectID]) try design.accept(frame) - let derived = design.createFrame(deriving: design.currentFrame) + let derived = design.createFrame(deriving: design.currentFrame!) let derivedSnap = derived.mutate(obj.objectID) #expect(derivedSnap.objectID == originalSnap.objectID) @@ -103,7 +93,7 @@ import Testing let obj = frame.create(TestType, attributes: ["text": "hello"]) try design.accept(frame) - let derived = design.createFrame(deriving: design.currentFrame) + let derived = design.createFrame(deriving: design.currentFrame!) let derivedSnap = derived.mutate(obj.objectID) #expect(derivedSnap["text"] == "hello") @@ -146,7 +136,7 @@ import Testing } @Test func onlyOriginalsRemoved() throws { - let originalNode = frame.create(TestNodeType) + let originalNode = frame.create(TestNodeType, structure: .node) let original = try design.accept(frame) let trans = design.createFrame(deriving: original) @@ -166,7 +156,7 @@ import Testing } @Test func replaceObject() throws { - let originalNode = frame.create(TestNodeType) + let originalNode = frame.create(TestNodeType, structure: .node) let original = try design.accept(frame) let trans = design.createFrame(deriving: original) @@ -187,7 +177,7 @@ import Testing let originalSnap = original.create(TestType) try design.accept(original) - let derived = design.createFrame(deriving: design.currentFrame) + let derived = design.createFrame(deriving: design.currentFrame!) #expect(derived.contains(snapshotID: originalSnap.snapshotID)) @@ -313,9 +303,9 @@ import Testing } @Test func deriveObjectPreservesParentChild() throws { - let obj = frame.create(TestNodeType) - let parent = frame.create(TestNodeType) - let child = frame.create(TestNodeType) + let obj = frame.create(TestNodeType, structure: .node) + let parent = frame.create(TestNodeType, structure: .node) + let child = frame.create(TestNodeType, structure: .node) frame.setParent(obj.objectID, to: parent.objectID) frame.setParent(child.objectID, to: obj.objectID) @@ -335,9 +325,9 @@ import Testing } @Test func parentChildIsPreservedOnAccept() throws { - let obj = frame.create(TestNodeType) - let parent = frame.create(TestNodeType) - let child = frame.create(TestNodeType) + let obj = frame.create(TestNodeType, structure: .node) + let parent = frame.create(TestNodeType, structure: .node) + let child = frame.create(TestNodeType, structure: .node) frame.setParent(obj.objectID, to: parent.objectID) frame.setParent(child.objectID, to: obj.objectID) @@ -352,13 +342,13 @@ import Testing // MARK: References and Referential Integrity @Test func brokenReferences() throws { - frame.create(TestEdgeType, - objectID: 5, - structure: .edge(30, 40), - parent: 10, - children: [20]) + let object = frame.create(TestEdgeType, + objectID: 5, + structure: .edge(30, 40), + parent: 10, + children: [20]) - let refs = frame.brokenReferences() + let refs = StructuralValidator.brokenReferences(object,in: frame) #expect(refs.count == 4) #expect(refs.contains(10)) @@ -368,34 +358,36 @@ import Testing } @Test func rejectBrokenEdgeEndpoint() throws { - frame.create(TestEdgeType, objectID: 10, structure: .edge(900, 901)) - - #expect(frame.brokenReferences().count == 2) - #expect(frame.brokenReferences().contains(ObjectID(900))) - #expect(frame.brokenReferences().contains(ObjectID(901))) + let object = frame.create(TestEdgeType, objectID: 10, structure: .edge(900, 901)) + let refs = StructuralValidator.brokenReferences(object, in: frame) + #expect(refs.count == 2) + #expect(refs.contains(ObjectID(900))) + #expect(refs.contains(ObjectID(901))) #expect(throws: StructuralIntegrityError.brokenStructureReference) { - try frame.validateStructure() + try StructuralValidator.validate(snapshots: frame.snapshots, in: frame) } } @Test func rejectMissingParent() throws { - frame.create(TestType, objectID: 20, parent: 902) + let object = frame.create(TestType, objectID: 20, parent: 902) + let refs = StructuralValidator.brokenReferences(object, in: frame) - #expect(frame.brokenReferences().count == 1) - #expect(frame.brokenReferences().contains(ObjectID(902))) + #expect(refs.count == 1) + #expect(refs.contains(ObjectID(902))) #expect(throws: StructuralIntegrityError.brokenParent) { - try frame.validateStructure() + try StructuralValidator.validate(snapshots: frame.snapshots, in: frame) } } @Test func rejectMissingChild() throws { - frame.create(TestType, objectID: 20, children: [903]) + let object = frame.create(TestType, objectID: 20, children: [903]) + let refs = StructuralValidator.brokenReferences(object, in: frame) - #expect(frame.brokenReferences().count == 1) - #expect(frame.brokenReferences().contains(903)) + #expect(refs.count == 1) + #expect(refs.contains(903)) #expect(throws: StructuralIntegrityError.brokenChild) { - try frame.validateStructure() + try StructuralValidator.validate(snapshots: frame.snapshots, in: frame) } } @@ -405,7 +397,7 @@ import Testing frame.create(TestType, objectID: 30) #expect { - try frame.validateStructure() + try StructuralValidator.validate(snapshots: frame.snapshots, in: frame) } throws: { guard let error = $0 as? StructuralIntegrityError else { return false @@ -419,7 +411,7 @@ import Testing frame.create(TestType, objectID: 30) #expect { - try frame.validateStructure() + try StructuralValidator.validate(snapshots: frame.snapshots, in: frame) } throws: { guard let error = $0 as? StructuralIntegrityError else { return false @@ -433,7 +425,7 @@ import Testing frame.create(TestType, objectID: 30, parent: 10, children: [10]) #expect{ - try frame.validateStructure() + try StructuralValidator.validate(snapshots: frame.snapshots, in: frame) } throws: { guard let error = $0 as? StructuralIntegrityError else { return false @@ -448,7 +440,7 @@ import Testing frame.create(TestType, objectID: 20) #expect { - try frame.validateStructure() + try StructuralValidator.validate(snapshots: frame.snapshots, in: frame) } throws: { guard let error = $0 as? StructuralIntegrityError else { return false diff --git a/Tests/PoieticCoreTests/Foreign/RawDesignLoaderTest.swift b/Tests/PoieticCoreTests/Foreign/RawDesignLoaderTest.swift index 5122866d..9a04199d 100644 --- a/Tests/PoieticCoreTests/Foreign/RawDesignLoaderTest.swift +++ b/Tests/PoieticCoreTests/Foreign/RawDesignLoaderTest.swift @@ -589,6 +589,32 @@ struct DesignLoaderIntegrationTests { #expect(o4.children.isEmpty == true) } + @Test("Load without frames - orphaned snapshots") + func loadOrphanedSnapshots() async throws { + let raw = RawDesign( + snapshots: [ + RawSnapshot(typeName: "TestPlain", snapshotID: .int(100), id: .int(10)), + RawSnapshot(typeName: "TestNode", snapshotID: .int(101), id: .int(11), structure: RawStructure("node")), + ], + frames: [ /* MUST be empty for this test */ ] + ) + let loader = DesignLoader(metamodel: TestMetamodel, options: .collectOrphans) + let design = try loader.load(raw) + + #expect(design.frames.count == 1) + let frame = try #require(design.frames.first) + #expect(design.currentFrameID == frame.id) + + let o0 = try #require(design.snapshot(ObjectSnapshotID(100))) + #expect(o0.objectID == ObjectID(10)) + #expect(o0.snapshotID == ObjectSnapshotID(100)) + + let o1 = try #require(design.snapshot(ObjectSnapshotID(101))) + #expect(o1.objectID == ObjectID(11)) + #expect(o1.snapshotID == ObjectSnapshotID(101)) + + } + @Test("Identity manager uses loaded IDs") func identityManagerUsesLoadedIDs() async throws { let raw = RawDesign( diff --git a/Tests/PoieticCoreTests/Runtime/AugmentedFrameTests.swift b/Tests/PoieticCoreTests/Runtime/AugmentedFrameTests.swift new file mode 100644 index 00000000..fd262194 --- /dev/null +++ b/Tests/PoieticCoreTests/Runtime/AugmentedFrameTests.swift @@ -0,0 +1,264 @@ +// +// AugmentedFrameTests.swift +// poietic-core +// +// Created by Stefan Urbanek on 30/10/2024. +// + +import Testing +@testable import PoieticCore + +// Test frame-level component +struct TestFrameComponent: Component, Equatable { + var orderedIDs: [ObjectID] + + init(orderedIDs: [ObjectID] = []) { + self.orderedIDs = orderedIDs + } +} + +@Suite struct AugmentedFrameTests { + let design: Design + let validatedFrame: DesignFrame + let objectIDs: [ObjectID] // IDs of created objects for easy reference + + init() throws { + // Create a test design with a few objects + self.design = Design(metamodel: TestMetamodel) + let frame = design.createFrame() + + // Create some test objects with proper structure + let obj1 = frame.create(.Stock, structure: .node) + let obj2 = frame.create(.FlowRate, structure: .node) + let obj3 = frame.create(.Stock, structure: .node) + + self.objectIDs = [obj1.objectID, obj2.objectID, obj3.objectID] + + // Accept and validate + self.validatedFrame = try design.accept(frame) + } + + // MARK: - Basics + + @Test func createaugmented() throws { + let augmented = AugmentedFrame(validatedFrame) + + #expect(augmented.id == validatedFrame.id) + #expect(augmented.snapshots.count == 3) + #expect(!augmented.hasIssues) + } + + @Test func delegatesFrameProtocol() throws { + let augmented = AugmentedFrame(validatedFrame) + + // Should delegate all Frame protocol methods + #expect(augmented.contains(objectIDs[0])) + #expect(augmented.object(objectIDs[0]) != nil) + #expect(augmented.objectIDs.count == 3) + #expect(augmented.design === design) + } + + // MARK: - Object Components + + @Test func setAndGetComponent() throws { + let augmented = AugmentedFrame(validatedFrame) + let objectID = objectIDs[0] + + let component = TestComponent(text: "test value") + augmented.setComponent(component, for: .object(objectID)) + + let retrieved: TestComponent = try #require(augmented.component(for: .object(objectID))) + #expect(retrieved.text == "test value") + } + + @Test func getComponentReturnsNilWhenNotSet() throws { + let augmented = AugmentedFrame(validatedFrame) + let objectID = objectIDs[0] + + let component: TestComponent? = augmented.component(for: .object(objectID)) + #expect(component == nil) + } + + @Test func replaceComponent() throws { + let augmented = AugmentedFrame(validatedFrame) + let objectID = objectIDs[0] + + augmented.setComponent(TestComponent(text: "first"), for: .object(objectID)) + augmented.setComponent(TestComponent(text: "second"), for: .object(objectID)) + + let retrieved: TestComponent = try #require(augmented.component(for: .object(objectID))) + #expect(retrieved.text == "second") + } + + @Test func hasComponent() throws { + let augmented = AugmentedFrame(validatedFrame) + let objectID = objectIDs[0] + + #expect(!augmented.hasComponent(TestComponent.self, for: .object(objectID))) + + augmented.setComponent(TestComponent(text: "test"), for: .object(objectID)) + + #expect(augmented.hasComponent(TestComponent.self, for: .object(objectID))) + } + + @Test func removeComponent() throws { + let augmented = AugmentedFrame(validatedFrame) + let objectID = objectIDs[0] + + augmented.setComponent(TestComponent(text: "test"), for: .object(objectID)) + #expect(augmented.hasComponent(TestComponent.self, for: .object(objectID))) + + augmented.removeComponent(TestComponent.self, for: .object(objectID)) + + #expect(!augmented.hasComponent(TestComponent.self, for: .object(objectID))) + let retrieved: TestComponent? = augmented.component(for: .object(objectID)) + #expect(retrieved == nil) + } + + @Test func multipleComponentsPerObject() throws { + let augmented = AugmentedFrame(validatedFrame) + let objectID = objectIDs[0] + + augmented.setComponent(TestComponent(text: "test"), for: .object(objectID)) + augmented.setComponent(IntegerComponent(value: 42), for: .object(objectID)) + + let testComp: TestComponent = try #require(augmented.component(for: .object(objectID))) + let intComp: IntegerComponent = try #require(augmented.component(for: .object(objectID))) + + #expect(testComp.text == "test") + #expect(intComp.value == 42) + } + + @Test func componentsIsolatedPerObject() throws { + let augmented = AugmentedFrame(validatedFrame) + let obj1 = objectIDs[0] + let obj2 = objectIDs[1] + + augmented.setComponent(TestComponent(text: "obj1"), for: .object(obj1)) + augmented.setComponent(TestComponent(text: "obj2"), for: .object(obj2)) + + let comp1: TestComponent = try #require(augmented.component(for: .object(obj1))) + let comp2: TestComponent = try #require(augmented.component(for: .object(obj2))) + + #expect(comp1.text == "obj1") + #expect(comp2.text == "obj2") + } + + // MARK: - Query + + @Test func objectIDsWithComponent() throws { + let augmented = AugmentedFrame(validatedFrame) + + var withComponent = augmented.objectIDs(with: TestComponent.self) + #expect(withComponent.isEmpty) + + augmented.setComponent(TestComponent(text: "test1"), for: .object(objectIDs[0])) + augmented.setComponent(TestComponent(text: "test2"), for: .object(objectIDs[2])) + + withComponent = augmented.objectIDs(with: TestComponent.self) + #expect(withComponent.count == 2) + #expect(withComponent.contains(objectIDs[0])) + #expect(withComponent.contains(objectIDs[2])) + #expect(!withComponent.contains(objectIDs[1])) + } + + @Test func queryByDifferentComponentTypes() throws { + let augmented = AugmentedFrame(validatedFrame) + + augmented.setComponent(TestComponent(text: "test"), for: .object(objectIDs[0])) + augmented.setComponent(IntegerComponent(value: 42), for: .object(objectIDs[1])) + augmented.setComponent(TestComponent(text: "test2"), for: .object(objectIDs[2])) + + let withText = augmented.objectIDs(with: TestComponent.self) + let withInt = augmented.objectIDs(with: IntegerComponent.self) + + #expect(withText.count == 2) + #expect(withInt.count == 1) + #expect(withText.contains(objectIDs[0])) + #expect(withText.contains(objectIDs[2])) + #expect(withInt.contains(objectIDs[1])) + } + + // MARK: - Frame + + @Test func setAndGetFrameComponent() throws { + let augmented = AugmentedFrame(validatedFrame) + + let component = TestFrameComponent(orderedIDs: objectIDs) + augmented.setComponent(component, for: .Frame) + + let retrieved: TestFrameComponent = try #require(augmented.component(for: .Frame)) + #expect(retrieved.orderedIDs == objectIDs) + } + + @Test func getFrameComponentReturnsNilWhenNotSet() throws { + let augmented = AugmentedFrame(validatedFrame) + + let component: TestFrameComponent? = augmented.component(for: .Frame) + #expect(component == nil) + } + + @Test func replaceFrameComponent() throws { + let augmented = AugmentedFrame(validatedFrame) + + augmented.setComponent(TestFrameComponent(orderedIDs: [objectIDs[0]]), for: .Frame) + augmented.setComponent(TestFrameComponent(orderedIDs: objectIDs), for: .Frame) + + let retrieved: TestFrameComponent = try #require(augmented.component(for: .Frame)) + #expect(retrieved.orderedIDs.count == 3) + } + + @Test func hasFrameComponent() throws { + let augmented = AugmentedFrame(validatedFrame) + + #expect(!augmented.hasComponent(TestFrameComponent.self, for: .Frame)) + + augmented.setComponent(TestFrameComponent(orderedIDs: objectIDs), for: .Frame) + + #expect(augmented.hasComponent(TestFrameComponent.self, for: .Frame)) + } + + @Test func removeFrameComponent() throws { + let augmented = AugmentedFrame(validatedFrame) + + // Set frame component + augmented.setComponent(TestFrameComponent(orderedIDs: objectIDs), for: .Frame) + #expect(augmented.hasComponent(TestFrameComponent.self, for: .Frame)) + + // Remove it + augmented.removeComponent(TestFrameComponent.self, for: .Frame) + + #expect(!augmented.hasComponent(TestFrameComponent.self, for: .Frame)) + let retrieved: TestFrameComponent? = augmented.component(for: .Frame) + #expect(retrieved == nil) + } + + @Test func multipleFrameComponents() throws { + let augmented = AugmentedFrame(validatedFrame) + + augmented.setComponent(TestFrameComponent(orderedIDs: objectIDs), for: .Frame) + augmented.setComponent(IntegerComponent(value: 100), for: .Frame) + + let orderComp: TestFrameComponent = try #require(augmented.component(for: .Frame)) + let intComp: IntegerComponent = try #require(augmented.component(for: .Frame)) + + #expect(orderComp.orderedIDs.count == 3) + #expect(intComp.value == 100) + } + + // MARK: - Object vs Frame Components + + @Test func objectAndFrameComponentsAreIndependent() throws { + let augmented = AugmentedFrame(validatedFrame) + let objectID = objectIDs[0] + + augmented.setComponent(IntegerComponent(value: 8), for: .object(objectID)) + augmented.setComponent(IntegerComponent(value: 100), for: .Frame) + + let objectComp: IntegerComponent = try #require(augmented.component(for: .object(objectID))) + let frameComp: IntegerComponent = try #require(augmented.component(for: .Frame)) + + #expect(objectComp.value == 8) + #expect(frameComp.value == 100) + } +} diff --git a/Tests/PoieticCoreTests/Runtime/RuntimeFrameTests.swift b/Tests/PoieticCoreTests/Runtime/RuntimeFrameTests.swift deleted file mode 100644 index 8fd6fd4e..00000000 --- a/Tests/PoieticCoreTests/Runtime/RuntimeFrameTests.swift +++ /dev/null @@ -1,265 +0,0 @@ -// -// RuntimeFrameTests.swift -// poietic-core -// -// Created by Stefan Urbanek on 30/10/2024. -// - -import Testing -@testable import PoieticCore - -// Test frame-level component -struct TestFrameComponent: Component, Equatable { - var orderedIDs: [ObjectID] - - init(orderedIDs: [ObjectID] = []) { - self.orderedIDs = orderedIDs - } -} - -@Suite struct RuntimeFrameTests { - let design: Design - let validatedFrame: ValidatedFrame - let objectIDs: [ObjectID] // IDs of created objects for easy reference - - init() throws { - // Create a test design with a few objects - self.design = Design(metamodel: TestMetamodel) - let frame = design.createFrame() - - // Create some test objects with proper structure - let obj1 = frame.create(.Stock, structure: .node) - let obj2 = frame.create(.FlowRate, structure: .node) - let obj3 = frame.create(.Stock, structure: .node) - - self.objectIDs = [obj1.objectID, obj2.objectID, obj3.objectID] - - // Accept and validate - try design.accept(frame) - self.validatedFrame = try design.validate(design.currentFrame!) - } - - // MARK: - Basics - - @Test func createRuntimeFrame() throws { - let runtimeFrame = RuntimeFrame(validatedFrame) - - #expect(runtimeFrame.id == validatedFrame.id) - #expect(runtimeFrame.snapshots.count == 3) - #expect(!runtimeFrame.hasIssues) - } - - @Test func delegatesFrameProtocol() throws { - let runtimeFrame = RuntimeFrame(validatedFrame) - - // Should delegate all Frame protocol methods - #expect(runtimeFrame.contains(objectIDs[0])) - #expect(runtimeFrame.object(objectIDs[0]) != nil) - #expect(runtimeFrame.objectIDs.count == 3) - #expect(runtimeFrame.design === design) - } - - // MARK: - Object Components - - @Test func setAndGetComponent() throws { - let runtimeFrame = RuntimeFrame(validatedFrame) - let objectID = objectIDs[0] - - let component = TestComponent(text: "test value") - runtimeFrame.setComponent(component, for: objectID) - - let retrieved: TestComponent = try #require(runtimeFrame.component(for: objectID)) - #expect(retrieved.text == "test value") - } - - @Test func getComponentReturnsNilWhenNotSet() throws { - let runtimeFrame = RuntimeFrame(validatedFrame) - let objectID = objectIDs[0] - - let component: TestComponent? = runtimeFrame.component(for: objectID) - #expect(component == nil) - } - - @Test func replaceComponent() throws { - let runtimeFrame = RuntimeFrame(validatedFrame) - let objectID = objectIDs[0] - - runtimeFrame.setComponent(TestComponent(text: "first"), for: objectID) - runtimeFrame.setComponent(TestComponent(text: "second"), for: objectID) - - let retrieved: TestComponent = try #require(runtimeFrame.component(for: objectID)) - #expect(retrieved.text == "second") - } - - @Test func hasComponent() throws { - let runtimeFrame = RuntimeFrame(validatedFrame) - let objectID = objectIDs[0] - - #expect(!runtimeFrame.hasComponent(TestComponent.self, for: objectID)) - - runtimeFrame.setComponent(TestComponent(text: "test"), for: objectID) - - #expect(runtimeFrame.hasComponent(TestComponent.self, for: objectID)) - } - - @Test func removeComponent() throws { - let runtimeFrame = RuntimeFrame(validatedFrame) - let objectID = objectIDs[0] - - runtimeFrame.setComponent(TestComponent(text: "test"), for: objectID) - #expect(runtimeFrame.hasComponent(TestComponent.self, for: objectID)) - - runtimeFrame.removeComponent(TestComponent.self, for: objectID) - - #expect(!runtimeFrame.hasComponent(TestComponent.self, for: objectID)) - let retrieved: TestComponent? = runtimeFrame.component(for: objectID) - #expect(retrieved == nil) - } - - @Test func multipleComponentsPerObject() throws { - let runtimeFrame = RuntimeFrame(validatedFrame) - let objectID = objectIDs[0] - - runtimeFrame.setComponent(TestComponent(text: "test"), for: objectID) - runtimeFrame.setComponent(IntegerComponent(value: 42), for: objectID) - - let testComp: TestComponent = try #require(runtimeFrame.component(for: objectID)) - let intComp: IntegerComponent = try #require(runtimeFrame.component(for: objectID)) - - #expect(testComp.text == "test") - #expect(intComp.value == 42) - } - - @Test func componentsIsolatedPerObject() throws { - let runtimeFrame = RuntimeFrame(validatedFrame) - let obj1 = objectIDs[0] - let obj2 = objectIDs[1] - - runtimeFrame.setComponent(TestComponent(text: "obj1"), for: obj1) - runtimeFrame.setComponent(TestComponent(text: "obj2"), for: obj2) - - let comp1: TestComponent = try #require(runtimeFrame.component(for: obj1)) - let comp2: TestComponent = try #require(runtimeFrame.component(for: obj2)) - - #expect(comp1.text == "obj1") - #expect(comp2.text == "obj2") - } - - // MARK: - Query - - @Test func objectIDsWithComponent() throws { - let runtimeFrame = RuntimeFrame(validatedFrame) - - var withComponent = runtimeFrame.objectIDs(with: TestComponent.self) - #expect(withComponent.isEmpty) - - runtimeFrame.setComponent(TestComponent(text: "test1"), for: objectIDs[0]) - runtimeFrame.setComponent(TestComponent(text: "test2"), for: objectIDs[2]) - - withComponent = runtimeFrame.objectIDs(with: TestComponent.self) - #expect(withComponent.count == 2) - #expect(withComponent.contains(objectIDs[0])) - #expect(withComponent.contains(objectIDs[2])) - #expect(!withComponent.contains(objectIDs[1])) - } - - @Test func queryByDifferentComponentTypes() throws { - let runtimeFrame = RuntimeFrame(validatedFrame) - - runtimeFrame.setComponent(TestComponent(text: "test"), for: objectIDs[0]) - runtimeFrame.setComponent(IntegerComponent(value: 42), for: objectIDs[1]) - runtimeFrame.setComponent(TestComponent(text: "test2"), for: objectIDs[2]) - - let withText = runtimeFrame.objectIDs(with: TestComponent.self) - let withInt = runtimeFrame.objectIDs(with: IntegerComponent.self) - - #expect(withText.count == 2) - #expect(withInt.count == 1) - #expect(withText.contains(objectIDs[0])) - #expect(withText.contains(objectIDs[2])) - #expect(withInt.contains(objectIDs[1])) - } - - // MARK: - Frame - - @Test func setAndGetFrameComponent() throws { - let runtimeFrame = RuntimeFrame(validatedFrame) - - let component = TestFrameComponent(orderedIDs: objectIDs) - runtimeFrame.setFrameComponent(component) - - let retrieved: TestFrameComponent = try #require(runtimeFrame.frameComponent(TestFrameComponent.self)) - #expect(retrieved.orderedIDs == objectIDs) - } - - @Test func getFrameComponentReturnsNilWhenNotSet() throws { - let runtimeFrame = RuntimeFrame(validatedFrame) - - let component: TestFrameComponent? = runtimeFrame.frameComponent(TestFrameComponent.self) - #expect(component == nil) - } - - @Test func replaceFrameComponent() throws { - let runtimeFrame = RuntimeFrame(validatedFrame) - - runtimeFrame.setFrameComponent(TestFrameComponent(orderedIDs: [objectIDs[0]])) - runtimeFrame.setFrameComponent(TestFrameComponent(orderedIDs: objectIDs)) - - let retrieved: TestFrameComponent = try #require(runtimeFrame.frameComponent(TestFrameComponent.self)) - #expect(retrieved.orderedIDs.count == 3) - } - - @Test func hasFrameComponent() throws { - let runtimeFrame = RuntimeFrame(validatedFrame) - - #expect(!runtimeFrame.hasFrameComponent(TestFrameComponent.self)) - - runtimeFrame.setFrameComponent(TestFrameComponent(orderedIDs: objectIDs)) - - #expect(runtimeFrame.hasFrameComponent(TestFrameComponent.self)) - } - - @Test func removeFrameComponent() throws { - let runtimeFrame = RuntimeFrame(validatedFrame) - - // Set frame component - runtimeFrame.setFrameComponent(TestFrameComponent(orderedIDs: objectIDs)) - #expect(runtimeFrame.hasFrameComponent(TestFrameComponent.self)) - - // Remove it - runtimeFrame.removeFrameComponent(TestFrameComponent.self) - - #expect(!runtimeFrame.hasFrameComponent(TestFrameComponent.self)) - let retrieved: TestFrameComponent? = runtimeFrame.frameComponent(TestFrameComponent.self) - #expect(retrieved == nil) - } - - @Test func multipleFrameComponents() throws { - let runtimeFrame = RuntimeFrame(validatedFrame) - - runtimeFrame.setFrameComponent(TestFrameComponent(orderedIDs: objectIDs)) - runtimeFrame.setFrameComponent(IntegerComponent(value: 100)) - - let orderComp: TestFrameComponent = try #require(runtimeFrame.frameComponent(TestFrameComponent.self)) - let intComp: IntegerComponent = try #require(runtimeFrame.frameComponent(IntegerComponent.self)) - - #expect(orderComp.orderedIDs.count == 3) - #expect(intComp.value == 100) - } - - // MARK: - Object vs Frame Components - - @Test func objectAndFrameComponentsAreIndependent() throws { - let runtimeFrame = RuntimeFrame(validatedFrame) - let objectID = objectIDs[0] - - runtimeFrame.setComponent(IntegerComponent(value: 8), for: objectID) - runtimeFrame.setFrameComponent(IntegerComponent(value: 100)) - - let objectComp: IntegerComponent = try #require(runtimeFrame.component(for: objectID)) - let frameComp: IntegerComponent = try #require(runtimeFrame.frameComponent(IntegerComponent.self)) - - #expect(objectComp.value == 8) - #expect(frameComp.value == 100) - } -}