Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed10d7d208 | |||
| 281936ddd9 | |||
| 453b310460 | |||
| 8b2ad60c5b | |||
| 6200c9327a |
@@ -0,0 +1,130 @@
|
||||
Feature: Project Configuration Models
|
||||
As a developer
|
||||
I want ValidationConfig and ContextConfig domain models
|
||||
So that projects can define validation commands and context indexing behavior
|
||||
|
||||
# ValidationConfig Tests (B1.5)
|
||||
|
||||
# B1.5a - Model creation with defaults
|
||||
|
||||
Scenario: Create a default ValidationConfig
|
||||
Given a default ValidationConfig
|
||||
Then the validation test_command should be none
|
||||
And the validation lint_command should be none
|
||||
And the validation type_check_command should be none
|
||||
And the validation build_command should be none
|
||||
And the validation custom_commands should be empty
|
||||
And the validation timeout_seconds should be 300
|
||||
And the validation fail_on_lint_error should be true
|
||||
|
||||
# B1.5b - Fields with values
|
||||
|
||||
Scenario: Create a ValidationConfig with all commands
|
||||
Given a ValidationConfig with test "pytest" lint "ruff check ." typecheck "pyright" build "python -m build"
|
||||
Then the validation test_command should be "pytest"
|
||||
And the validation lint_command should be "ruff check ."
|
||||
And the validation type_check_command should be "pyright"
|
||||
And the validation build_command should be "python -m build"
|
||||
|
||||
Scenario: Create a ValidationConfig with custom commands
|
||||
Given a ValidationConfig with custom commands "security=bandit,docs=mkdocs build"
|
||||
Then the validation custom_commands should contain "security" with value "bandit"
|
||||
And the validation custom_commands should contain "docs" with value "mkdocs build"
|
||||
|
||||
Scenario: Create a ValidationConfig with custom timeout
|
||||
Given a ValidationConfig with timeout 600
|
||||
Then the validation timeout_seconds should be 600
|
||||
|
||||
Scenario: Create a ValidationConfig with fail_on_lint_error false
|
||||
Given a ValidationConfig with fail_on_lint_error false
|
||||
Then the validation fail_on_lint_error should be false
|
||||
|
||||
# B1.5c - Helper methods
|
||||
|
||||
Scenario: get_all_commands returns all configured commands
|
||||
Given a ValidationConfig with test "pytest" lint "ruff check ." typecheck "pyright" build "python -m build"
|
||||
Then get_all_commands should include "test" with value "pytest"
|
||||
And get_all_commands should include "lint" with value "ruff check ."
|
||||
And get_all_commands should include "type_check" with value "pyright"
|
||||
And get_all_commands should include "build" with value "python -m build"
|
||||
|
||||
Scenario: get_all_commands includes custom commands
|
||||
Given a ValidationConfig with test "pytest" and custom commands "security=bandit"
|
||||
Then get_all_commands should include "test" with value "pytest"
|
||||
And get_all_commands should include "security" with value "bandit"
|
||||
|
||||
Scenario: get_all_commands returns empty dict when no commands configured
|
||||
Given a default ValidationConfig
|
||||
Then get_all_commands should be empty
|
||||
|
||||
Scenario: has_any_validation is true when commands exist
|
||||
Given a ValidationConfig with test "pytest" lint "ruff check ." typecheck "pyright" build "python -m build"
|
||||
Then has_any_validation should be true
|
||||
|
||||
Scenario: has_any_validation is false when no commands configured
|
||||
Given a default ValidationConfig
|
||||
Then has_any_validation should be false
|
||||
|
||||
Scenario: has_any_validation is true with only custom commands
|
||||
Given a ValidationConfig with custom commands "security=bandit,docs=mkdocs build"
|
||||
Then has_any_validation should be true
|
||||
|
||||
# ContextConfig Tests (B1.6)
|
||||
|
||||
# B1.6a - Model creation with defaults
|
||||
|
||||
Scenario: Create a default ContextConfig
|
||||
Given a default ContextConfig
|
||||
Then the context max_file_size should be 1000000
|
||||
And the context max_files should be 100000
|
||||
And the context indexing_strategy should be "full_text"
|
||||
And the context chunking_policy should be "smart"
|
||||
And the context chunk_size should be 1000
|
||||
And the context include_patterns should be none
|
||||
|
||||
Scenario: Default ContextConfig has default ignore patterns
|
||||
Given a default ContextConfig
|
||||
Then the context ignore_patterns should contain ".git/"
|
||||
And the context ignore_patterns should contain "node_modules/"
|
||||
And the context ignore_patterns should contain "__pycache__/"
|
||||
And the context ignore_patterns should contain ".venv/"
|
||||
And the context ignore_patterns should contain "*.pyc"
|
||||
And the context ignore_patterns should contain ".DS_Store"
|
||||
|
||||
# B1.6b - Fields with values
|
||||
|
||||
Scenario: Create a ContextConfig with custom ignore patterns
|
||||
Given a ContextConfig with ignore patterns "dist/,build/"
|
||||
Then the context ignore_patterns should contain "dist/"
|
||||
And the context ignore_patterns should contain "build/"
|
||||
And the context ignore_patterns should contain ".git/"
|
||||
|
||||
Scenario: Create a ContextConfig with include patterns
|
||||
Given a ContextConfig with include patterns "*.py,*.ts"
|
||||
Then the context include_patterns should contain "*.py"
|
||||
And the context include_patterns should contain "*.ts"
|
||||
|
||||
Scenario: Create a ContextConfig with custom max_file_size
|
||||
Given a ContextConfig with max_file_size 2000000
|
||||
Then the context max_file_size should be 2000000
|
||||
|
||||
Scenario: Create a ContextConfig with custom indexing strategy
|
||||
Given a ContextConfig with indexing_strategy "semantic"
|
||||
Then the context indexing_strategy should be "semantic"
|
||||
|
||||
Scenario: Create a ContextConfig with custom chunking policy
|
||||
Given a ContextConfig with chunking_policy "fixed"
|
||||
Then the context chunking_policy should be "fixed"
|
||||
|
||||
Scenario: Create a ContextConfig with custom chunk size
|
||||
Given a ContextConfig with chunk_size 500
|
||||
Then the context chunk_size should be 500
|
||||
|
||||
# B1.6c - Duplicate ignore patterns are deduplicated
|
||||
|
||||
Scenario: ContextConfig deduplicates ignore patterns that overlap with defaults
|
||||
Given a ContextConfig with ignore patterns ".git/,node_modules/,custom/"
|
||||
Then the context ignore_patterns should contain ".git/"
|
||||
And the context ignore_patterns should contain "node_modules/"
|
||||
And the context ignore_patterns should contain "custom/"
|
||||
And the context ignore_patterns should not contain duplicate ".git/"
|
||||
@@ -0,0 +1,141 @@
|
||||
Feature: Project Domain Model Extensions
|
||||
As a developer
|
||||
I want the Project model to support ULID identifiers, namespaces, resources, and configs
|
||||
So that projects can be properly categorized and managed
|
||||
|
||||
# B1.1a - Import Resource, verify Project has new fields
|
||||
|
||||
Scenario: Create a Project with new B1.1 fields
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" namespace "local"
|
||||
Then the project project_id should be "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
And the project name should be "my-project"
|
||||
And the project namespace should be "local"
|
||||
|
||||
# B1.1b - Identity fields
|
||||
|
||||
Scenario: Project has default namespace
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
|
||||
Then the project namespace should be "local"
|
||||
|
||||
Scenario: Project description defaults to none
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
|
||||
Then the project description should be none
|
||||
|
||||
Scenario: Create a Project with description
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" and description "A test project"
|
||||
Then the project description should be "A test project"
|
||||
|
||||
# B1.1c - Categorization fields
|
||||
|
||||
Scenario: Project tags default to empty list
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
|
||||
Then the project tags should be empty
|
||||
|
||||
Scenario: Create a Project with tags
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" and tags "python,web,api"
|
||||
Then the project tags should contain "python"
|
||||
And the project tags should contain "web"
|
||||
And the project tags should contain "api"
|
||||
|
||||
Scenario: Project resources default to empty list
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
|
||||
Then the project resources should be empty
|
||||
|
||||
Scenario: Project validation_config defaults to none
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
|
||||
Then the project validation_config should be none
|
||||
|
||||
Scenario: Project context_config has defaults
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
|
||||
Then the project context_config should not be none
|
||||
|
||||
# B1.1d - Timestamp fields
|
||||
|
||||
Scenario: Project has timestamps
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
|
||||
Then the project created_at should be set
|
||||
And the project updated_at should be set
|
||||
|
||||
# B1.1e - is_remote computed property
|
||||
|
||||
Scenario: Project with no resources is not remote
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
|
||||
Then the project is_remote should be false
|
||||
|
||||
Scenario: Project with all remote resources is remote
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" and all remote resources
|
||||
Then the project is_remote should be true
|
||||
|
||||
Scenario: Project with mixed resources is not remote
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" and mixed resources
|
||||
Then the project is_remote should be false
|
||||
|
||||
# B1.1f - Namespace validator
|
||||
|
||||
Scenario: Namespace local is valid
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" namespace "local"
|
||||
Then the project namespace should be "local"
|
||||
|
||||
Scenario: Namespace with valid pattern is accepted
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" namespace "team_alpha"
|
||||
Then the project namespace should be "team_alpha"
|
||||
|
||||
Scenario: Namespace system is rejected
|
||||
When I try to create a Project with namespace "system"
|
||||
Then a namespace validation error should be raised
|
||||
|
||||
Scenario: Namespace with uppercase is rejected
|
||||
When I try to create a Project with namespace "MyNamespace"
|
||||
Then a namespace validation error should be raised
|
||||
|
||||
Scenario: Namespace starting with number is rejected
|
||||
When I try to create a Project with namespace "1invalid"
|
||||
Then a namespace validation error should be raised
|
||||
|
||||
# B1.1g - Helper methods
|
||||
|
||||
Scenario: namespaced_name returns correct format
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" namespace "team_alpha"
|
||||
Then the project namespaced_name should be "team_alpha/my-project"
|
||||
|
||||
Scenario: namespaced_name with local namespace
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" namespace "local"
|
||||
Then the project namespaced_name should be "local/my-project"
|
||||
|
||||
Scenario: add_resource adds a resource to the project
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
|
||||
When I add a resource named "my-repo" to the project
|
||||
Then the project should have 1 resource
|
||||
And the project should have a resource named "my-repo"
|
||||
|
||||
Scenario: remove_resource removes a resource from the project
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" and a resource named "my-repo"
|
||||
When I remove the resource named "my-repo" from the project
|
||||
Then the project resources should be empty
|
||||
|
||||
Scenario: get_resource returns the correct resource
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" and a resource named "my-repo"
|
||||
When I get the resource named "my-repo" from the project
|
||||
Then the retrieved resource name should be "my-repo"
|
||||
|
||||
Scenario: get_resource returns none for unknown name
|
||||
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
|
||||
When I get the resource named "nonexistent" from the project
|
||||
Then the retrieved resource should be none
|
||||
|
||||
# B1.1h - parse_namespaced_name static method
|
||||
|
||||
Scenario: parse_namespaced_name splits valid namespaced string
|
||||
When I parse the project namespaced name "team_alpha/my-project"
|
||||
Then the parsed project namespace should be "team_alpha"
|
||||
And the parsed project name should be "my-project"
|
||||
|
||||
Scenario: parse_namespaced_name rejects string without slash
|
||||
When I try to parse the project namespaced name "no-slash-here"
|
||||
Then a project namespaced name parse error should be raised
|
||||
|
||||
# B1.1i - Name validation edge cases
|
||||
|
||||
Scenario: Project rejects name with special characters
|
||||
When I try to create a Project with invalid name "bad@name!"
|
||||
Then a name validation error should be raised
|
||||
@@ -0,0 +1,204 @@
|
||||
Feature: Resource Domain Model
|
||||
As a developer
|
||||
I want resource types and sandbox strategies defined as domain enums
|
||||
So that the system can classify project resources and determine sandboxing behavior
|
||||
|
||||
# ResourceType Enum Tests (B1.3)
|
||||
|
||||
Scenario: ResourceType has all required values
|
||||
Then the resource types should be "git_repository, filesystem, database, api_endpoint, document_corpus, cloud_infrastructure"
|
||||
|
||||
Scenario: ResourceType GIT_REPOSITORY has correct value
|
||||
When I access ResourceType.GIT_REPOSITORY
|
||||
Then the resource type value should be "git_repository"
|
||||
|
||||
Scenario: ResourceType FILESYSTEM has correct value
|
||||
When I access ResourceType.FILESYSTEM
|
||||
Then the resource type value should be "filesystem"
|
||||
|
||||
Scenario: ResourceType DATABASE has correct value
|
||||
When I access ResourceType.DATABASE
|
||||
Then the resource type value should be "database"
|
||||
|
||||
Scenario: ResourceType API_ENDPOINT has correct value
|
||||
When I access ResourceType.API_ENDPOINT
|
||||
Then the resource type value should be "api_endpoint"
|
||||
|
||||
Scenario: ResourceType DOCUMENT_CORPUS has correct value
|
||||
When I access ResourceType.DOCUMENT_CORPUS
|
||||
Then the resource type value should be "document_corpus"
|
||||
|
||||
Scenario: ResourceType CLOUD_INFRASTRUCTURE has correct value
|
||||
When I access ResourceType.CLOUD_INFRASTRUCTURE
|
||||
Then the resource type value should be "cloud_infrastructure"
|
||||
|
||||
Scenario: ResourceType is a string enum
|
||||
When I access ResourceType.GIT_REPOSITORY
|
||||
Then the resource type should be a string
|
||||
|
||||
Scenario: ResourceType can be created from string value
|
||||
When I create a ResourceType from string "filesystem"
|
||||
Then the resource type value should be "filesystem"
|
||||
|
||||
Scenario: Invalid resource type string raises error
|
||||
When I try to create a ResourceType from string "invalid_type"
|
||||
Then a ValueError should be raised
|
||||
|
||||
# SandboxStrategy Enum Tests (B1.4)
|
||||
|
||||
Scenario: SandboxStrategy has all required values
|
||||
Then the sandbox strategies should be "git_worktree, copy_on_write, overlay, transaction_rollback, versioning, none"
|
||||
|
||||
Scenario: SandboxStrategy GIT_WORKTREE has correct value
|
||||
When I access SandboxStrategy.GIT_WORKTREE
|
||||
Then the sandbox strategy value should be "git_worktree"
|
||||
|
||||
Scenario: SandboxStrategy COPY_ON_WRITE has correct value
|
||||
When I access SandboxStrategy.COPY_ON_WRITE
|
||||
Then the sandbox strategy value should be "copy_on_write"
|
||||
|
||||
Scenario: SandboxStrategy OVERLAY has correct value
|
||||
When I access SandboxStrategy.OVERLAY
|
||||
Then the sandbox strategy value should be "overlay"
|
||||
|
||||
Scenario: SandboxStrategy TRANSACTION_ROLLBACK has correct value
|
||||
When I access SandboxStrategy.TRANSACTION_ROLLBACK
|
||||
Then the sandbox strategy value should be "transaction_rollback"
|
||||
|
||||
Scenario: SandboxStrategy VERSIONING has correct value
|
||||
When I access SandboxStrategy.VERSIONING
|
||||
Then the sandbox strategy value should be "versioning"
|
||||
|
||||
Scenario: SandboxStrategy NONE has correct value
|
||||
When I access SandboxStrategy.NONE
|
||||
Then the sandbox strategy value should be "none"
|
||||
|
||||
Scenario: SandboxStrategy is a string enum
|
||||
When I access SandboxStrategy.GIT_WORKTREE
|
||||
Then the sandbox strategy should be a string
|
||||
|
||||
# SandboxStrategy.supports_rollback Tests (B1.4b)
|
||||
|
||||
Scenario: GIT_WORKTREE supports rollback
|
||||
Then SandboxStrategy.GIT_WORKTREE should support rollback
|
||||
|
||||
Scenario: COPY_ON_WRITE supports rollback
|
||||
Then SandboxStrategy.COPY_ON_WRITE should support rollback
|
||||
|
||||
Scenario: OVERLAY supports rollback
|
||||
Then SandboxStrategy.OVERLAY should support rollback
|
||||
|
||||
Scenario: TRANSACTION_ROLLBACK supports rollback
|
||||
Then SandboxStrategy.TRANSACTION_ROLLBACK should support rollback
|
||||
|
||||
Scenario: VERSIONING supports rollback
|
||||
Then SandboxStrategy.VERSIONING should support rollback
|
||||
|
||||
Scenario: NONE does not support rollback
|
||||
Then SandboxStrategy.NONE should not support rollback
|
||||
|
||||
# SandboxStrategy.is_copy_based Tests (B1.4b)
|
||||
|
||||
Scenario: COPY_ON_WRITE is copy based
|
||||
Then SandboxStrategy.COPY_ON_WRITE should be copy based
|
||||
|
||||
Scenario: OVERLAY is copy based
|
||||
Then SandboxStrategy.OVERLAY should be copy based
|
||||
|
||||
Scenario: GIT_WORKTREE is not copy based
|
||||
Then SandboxStrategy.GIT_WORKTREE should not be copy based
|
||||
|
||||
Scenario: TRANSACTION_ROLLBACK is not copy based
|
||||
Then SandboxStrategy.TRANSACTION_ROLLBACK should not be copy based
|
||||
|
||||
Scenario: VERSIONING is not copy based
|
||||
Then SandboxStrategy.VERSIONING should not be copy based
|
||||
|
||||
Scenario: NONE is not copy based
|
||||
Then SandboxStrategy.NONE should not be copy based
|
||||
|
||||
# Resource Pydantic Model Tests (B1.2)
|
||||
|
||||
# B1.2a - Model structure (frozen=True)
|
||||
|
||||
Scenario: Create a valid Resource with all required fields
|
||||
Given a Resource with required fields id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo"
|
||||
Then the resource name should be "my-repo"
|
||||
And the resource type should be ResourceType.GIT_REPOSITORY
|
||||
And the resource location should be "/tmp/repo"
|
||||
|
||||
Scenario: Resource model is frozen
|
||||
Given I have a valid Resource
|
||||
When I try to modify the resource name
|
||||
Then a validation error should be raised
|
||||
|
||||
# B1.2b - All fields present with defaults
|
||||
|
||||
Scenario: Resource has correct default values
|
||||
Given a Resource with required fields id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo"
|
||||
Then the resource is_remote should be false
|
||||
And the resource sandbox_strategy should be SandboxStrategy.NONE
|
||||
And the resource read_only should be false
|
||||
And the resource metadata should be empty
|
||||
And the resource created_at should be set
|
||||
|
||||
Scenario: Create a remote Resource
|
||||
Given a remote Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "api-svc" type "api_endpoint" location "https://api.example.com"
|
||||
Then the resource is_remote should be true
|
||||
|
||||
Scenario: Create a read-only Resource
|
||||
Given a read-only Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "docs" type "document_corpus" location "/data/docs"
|
||||
Then the resource read_only should be true
|
||||
|
||||
Scenario: Create a Resource with custom sandbox strategy
|
||||
Given a Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo" strategy "git_worktree"
|
||||
Then the resource sandbox_strategy should be SandboxStrategy.GIT_WORKTREE
|
||||
|
||||
Scenario: Create a Resource with metadata
|
||||
Given a Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo" metadata "branch=main,remote=origin"
|
||||
Then the resource metadata should contain key "branch" with value "main"
|
||||
And the resource metadata should contain key "remote" with value "origin"
|
||||
|
||||
# B1.2c - Validators
|
||||
|
||||
Scenario: Reject Resource with invalid ULID
|
||||
When I try to create a Resource with id "not-a-ulid" name "my-repo" type "git_repository" location "/tmp/repo"
|
||||
Then a validation error should be raised
|
||||
|
||||
Scenario: Reject Resource with empty name
|
||||
When I try to create a Resource with an empty name
|
||||
Then a validation error should be raised
|
||||
|
||||
Scenario: Reject Resource with empty location
|
||||
When I try to create a Resource with an empty location
|
||||
Then a validation error should be raised
|
||||
|
||||
Scenario: Resource name is lowercased
|
||||
Given a Resource with required fields id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "My-Repo" type "git_repository" location "/tmp/repo"
|
||||
Then the resource name should be "my-repo"
|
||||
|
||||
Scenario: Reject Resource with invalid name characters
|
||||
When I try to create a Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my repo!!" type "git_repository" location "/tmp/repo"
|
||||
Then a validation error should be raised
|
||||
|
||||
# B1.2d - Properties
|
||||
|
||||
Scenario: Resource with sandbox strategy supports sandbox
|
||||
Given a Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo" strategy "git_worktree"
|
||||
Then the resource supports_sandbox should be true
|
||||
|
||||
Scenario: Resource with NONE sandbox strategy does not support sandbox
|
||||
Given a Resource with required fields id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo"
|
||||
Then the resource supports_sandbox should be false
|
||||
|
||||
Scenario: Writable Resource can_write is true
|
||||
Given a Resource with required fields id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo"
|
||||
Then the resource can_write should be true
|
||||
|
||||
Scenario: Read-only Resource can_write is false
|
||||
Given a read-only Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "docs" type "document_corpus" location "/data/docs"
|
||||
Then the resource can_write should be false
|
||||
|
||||
Scenario: Resource get_sandbox_path returns correct path
|
||||
Given a Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo" strategy "git_worktree"
|
||||
Then the resource get_sandbox_path with base "/sandbox" should end with "my-repo"
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Step definitions for project configuration model tests (B1.5, B1.6)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.domain.models.core.project import ContextConfig, ValidationConfig
|
||||
|
||||
# ValidationConfig Steps (B1.5)
|
||||
|
||||
|
||||
@given("a default ValidationConfig")
|
||||
def step_default_validation_config(context: Context) -> None:
|
||||
"""Create a ValidationConfig with all defaults."""
|
||||
context.validation_config = ValidationConfig()
|
||||
|
||||
|
||||
@given(
|
||||
'a ValidationConfig with test "{test}" lint "{lint}" typecheck "{typecheck}" build "{build}"'
|
||||
)
|
||||
def step_validation_config_all_commands(
|
||||
context: Context, test: str, lint: str, typecheck: str, build: str
|
||||
) -> None:
|
||||
"""Create a ValidationConfig with all standard commands."""
|
||||
context.validation_config = ValidationConfig(
|
||||
test_command=test,
|
||||
lint_command=lint,
|
||||
type_check_command=typecheck,
|
||||
build_command=build,
|
||||
)
|
||||
|
||||
|
||||
@given('a ValidationConfig with custom commands "{commands_str}"')
|
||||
def step_validation_config_custom_commands(context: Context, commands_str: str) -> None:
|
||||
"""Create a ValidationConfig with custom commands parsed from key=value pairs."""
|
||||
custom = {}
|
||||
for pair in commands_str.split(","):
|
||||
key, value = pair.strip().split("=", 1)
|
||||
custom[key.strip()] = value.strip()
|
||||
context.validation_config = ValidationConfig(custom_commands=custom)
|
||||
|
||||
|
||||
@given("a ValidationConfig with timeout {timeout:d}")
|
||||
def step_validation_config_timeout(context: Context, timeout: int) -> None:
|
||||
"""Create a ValidationConfig with custom timeout."""
|
||||
context.validation_config = ValidationConfig(timeout_seconds=timeout)
|
||||
|
||||
|
||||
@given("a ValidationConfig with fail_on_lint_error false")
|
||||
def step_validation_config_no_lint_fail(context: Context) -> None:
|
||||
"""Create a ValidationConfig with fail_on_lint_error disabled."""
|
||||
context.validation_config = ValidationConfig(fail_on_lint_error=False)
|
||||
|
||||
|
||||
@given('a ValidationConfig with test "{test}" and custom commands "{commands_str}"')
|
||||
def step_validation_config_test_and_custom(
|
||||
context: Context, test: str, commands_str: str
|
||||
) -> None:
|
||||
"""Create a ValidationConfig with test command and custom commands."""
|
||||
custom = {}
|
||||
for pair in commands_str.split(","):
|
||||
key, value = pair.strip().split("=", 1)
|
||||
custom[key.strip()] = value.strip()
|
||||
context.validation_config = ValidationConfig(
|
||||
test_command=test, custom_commands=custom
|
||||
)
|
||||
|
||||
|
||||
# ValidationConfig field assertions
|
||||
|
||||
|
||||
@then("the validation test_command should be none")
|
||||
def step_check_test_command_none(context: Context) -> None:
|
||||
"""Verify test_command is None."""
|
||||
assert context.validation_config.test_command is None
|
||||
|
||||
|
||||
@then('the validation test_command should be "{expected}"')
|
||||
def step_check_test_command(context: Context, expected: str) -> None:
|
||||
"""Verify test_command value."""
|
||||
assert context.validation_config.test_command == expected, (
|
||||
f"Expected '{expected}', got '{context.validation_config.test_command}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the validation lint_command should be none")
|
||||
def step_check_lint_command_none(context: Context) -> None:
|
||||
"""Verify lint_command is None."""
|
||||
assert context.validation_config.lint_command is None
|
||||
|
||||
|
||||
@then('the validation lint_command should be "{expected}"')
|
||||
def step_check_lint_command(context: Context, expected: str) -> None:
|
||||
"""Verify lint_command value."""
|
||||
assert context.validation_config.lint_command == expected, (
|
||||
f"Expected '{expected}', got '{context.validation_config.lint_command}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the validation type_check_command should be none")
|
||||
def step_check_typecheck_command_none(context: Context) -> None:
|
||||
"""Verify type_check_command is None."""
|
||||
assert context.validation_config.type_check_command is None
|
||||
|
||||
|
||||
@then('the validation type_check_command should be "{expected}"')
|
||||
def step_check_typecheck_command(context: Context, expected: str) -> None:
|
||||
"""Verify type_check_command value."""
|
||||
assert context.validation_config.type_check_command == expected, (
|
||||
f"Expected '{expected}', got '{context.validation_config.type_check_command}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the validation build_command should be none")
|
||||
def step_check_build_command_none(context: Context) -> None:
|
||||
"""Verify build_command is None."""
|
||||
assert context.validation_config.build_command is None
|
||||
|
||||
|
||||
@then('the validation build_command should be "{expected}"')
|
||||
def step_check_build_command(context: Context, expected: str) -> None:
|
||||
"""Verify build_command value."""
|
||||
assert context.validation_config.build_command == expected, (
|
||||
f"Expected '{expected}', got '{context.validation_config.build_command}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the validation custom_commands should be empty")
|
||||
def step_check_custom_commands_empty(context: Context) -> None:
|
||||
"""Verify custom_commands is empty dict."""
|
||||
assert context.validation_config.custom_commands == {}
|
||||
|
||||
|
||||
@then('the validation custom_commands should contain "{key}" with value "{value}"')
|
||||
def step_check_custom_command(context: Context, key: str, value: str) -> None:
|
||||
"""Verify custom_commands contains expected key-value pair."""
|
||||
assert key in context.validation_config.custom_commands, (
|
||||
f"Expected key '{key}' in custom_commands"
|
||||
)
|
||||
assert context.validation_config.custom_commands[key] == value, (
|
||||
f"Expected custom_commands['{key}'] = '{value}', "
|
||||
f"got '{context.validation_config.custom_commands[key]}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the validation timeout_seconds should be {expected:d}")
|
||||
def step_check_timeout(context: Context, expected: int) -> None:
|
||||
"""Verify timeout_seconds value."""
|
||||
assert context.validation_config.timeout_seconds == expected
|
||||
|
||||
|
||||
@then("the validation fail_on_lint_error should be true")
|
||||
def step_check_fail_on_lint_true(context: Context) -> None:
|
||||
"""Verify fail_on_lint_error is True."""
|
||||
assert context.validation_config.fail_on_lint_error is True
|
||||
|
||||
|
||||
@then("the validation fail_on_lint_error should be false")
|
||||
def step_check_fail_on_lint_false(context: Context) -> None:
|
||||
"""Verify fail_on_lint_error is False."""
|
||||
assert context.validation_config.fail_on_lint_error is False
|
||||
|
||||
|
||||
# ValidationConfig method assertions
|
||||
|
||||
|
||||
@then('get_all_commands should include "{key}" with value "{value}"')
|
||||
def step_check_get_all_commands_key(context: Context, key: str, value: str) -> None:
|
||||
"""Verify get_all_commands includes expected key-value pair."""
|
||||
commands = context.validation_config.get_all_commands()
|
||||
assert key in commands, (
|
||||
f"Expected key '{key}' in get_all_commands(), got {commands}"
|
||||
)
|
||||
assert commands[key] == value, (
|
||||
f"Expected commands['{key}'] = '{value}', got '{commands[key]}'"
|
||||
)
|
||||
|
||||
|
||||
@then("get_all_commands should be empty")
|
||||
def step_check_get_all_commands_empty(context: Context) -> None:
|
||||
"""Verify get_all_commands returns empty dict."""
|
||||
commands = context.validation_config.get_all_commands()
|
||||
assert commands == {}, f"Expected empty dict, got {commands}"
|
||||
|
||||
|
||||
@then("has_any_validation should be true")
|
||||
def step_check_has_validation_true(context: Context) -> None:
|
||||
"""Verify has_any_validation returns True."""
|
||||
assert context.validation_config.has_any_validation() is True
|
||||
|
||||
|
||||
@then("has_any_validation should be false")
|
||||
def step_check_has_validation_false(context: Context) -> None:
|
||||
"""Verify has_any_validation returns False."""
|
||||
assert context.validation_config.has_any_validation() is False
|
||||
|
||||
|
||||
# ContextConfig Steps (B1.6)
|
||||
|
||||
|
||||
@given("a default ContextConfig")
|
||||
def step_default_context_config(context: Context) -> None:
|
||||
"""Create a ContextConfig with all defaults."""
|
||||
context.context_config = ContextConfig()
|
||||
|
||||
|
||||
@given('a ContextConfig with ignore patterns "{patterns}"')
|
||||
def step_context_config_ignore_patterns(context: Context, patterns: str) -> None:
|
||||
"""Create a ContextConfig with custom ignore patterns."""
|
||||
pattern_list = [p.strip() for p in patterns.split(",")]
|
||||
context.context_config = ContextConfig(ignore_patterns=pattern_list)
|
||||
|
||||
|
||||
@given('a ContextConfig with include patterns "{patterns}"')
|
||||
def step_context_config_include_patterns(context: Context, patterns: str) -> None:
|
||||
"""Create a ContextConfig with include patterns."""
|
||||
pattern_list = [p.strip() for p in patterns.split(",")]
|
||||
context.context_config = ContextConfig(include_patterns=pattern_list)
|
||||
|
||||
|
||||
@given("a ContextConfig with max_file_size {size:d}")
|
||||
def step_context_config_max_file_size(context: Context, size: int) -> None:
|
||||
"""Create a ContextConfig with custom max_file_size."""
|
||||
context.context_config = ContextConfig(max_file_size=size)
|
||||
|
||||
|
||||
@given('a ContextConfig with indexing_strategy "{strategy}"')
|
||||
def step_context_config_indexing_strategy(context: Context, strategy: str) -> None:
|
||||
"""Create a ContextConfig with custom indexing strategy."""
|
||||
context.context_config = ContextConfig(indexing_strategy=strategy)
|
||||
|
||||
|
||||
@given('a ContextConfig with chunking_policy "{policy}"')
|
||||
def step_context_config_chunking_policy(context: Context, policy: str) -> None:
|
||||
"""Create a ContextConfig with custom chunking policy."""
|
||||
context.context_config = ContextConfig(chunking_policy=policy)
|
||||
|
||||
|
||||
@given("a ContextConfig with chunk_size {size:d}")
|
||||
def step_context_config_chunk_size(context: Context, size: int) -> None:
|
||||
"""Create a ContextConfig with custom chunk size."""
|
||||
context.context_config = ContextConfig(chunk_size=size)
|
||||
|
||||
|
||||
# ContextConfig field assertions
|
||||
|
||||
|
||||
@then("the context max_file_size should be {expected:d}")
|
||||
def step_check_max_file_size(context: Context, expected: int) -> None:
|
||||
"""Verify max_file_size value."""
|
||||
assert context.context_config.max_file_size == expected
|
||||
|
||||
|
||||
@then("the context max_files should be {expected:d}")
|
||||
def step_check_max_files(context: Context, expected: int) -> None:
|
||||
"""Verify max_files value."""
|
||||
assert context.context_config.max_files == expected
|
||||
|
||||
|
||||
@then('the context indexing_strategy should be "{expected}"')
|
||||
def step_check_indexing_strategy(context: Context, expected: str) -> None:
|
||||
"""Verify indexing_strategy value."""
|
||||
assert context.context_config.indexing_strategy == expected
|
||||
|
||||
|
||||
@then('the context chunking_policy should be "{expected}"')
|
||||
def step_check_chunking_policy(context: Context, expected: str) -> None:
|
||||
"""Verify chunking_policy value."""
|
||||
assert context.context_config.chunking_policy == expected
|
||||
|
||||
|
||||
@then("the context chunk_size should be {expected:d}")
|
||||
def step_check_chunk_size(context: Context, expected: int) -> None:
|
||||
"""Verify chunk_size value."""
|
||||
assert context.context_config.chunk_size == expected
|
||||
|
||||
|
||||
@then("the context include_patterns should be none")
|
||||
def step_check_include_patterns_none(context: Context) -> None:
|
||||
"""Verify include_patterns is None."""
|
||||
assert context.context_config.include_patterns is None
|
||||
|
||||
|
||||
@then('the context ignore_patterns should contain "{pattern}"')
|
||||
def step_check_ignore_pattern(context: Context, pattern: str) -> None:
|
||||
"""Verify ignore_patterns contains expected pattern."""
|
||||
assert pattern in context.context_config.ignore_patterns, (
|
||||
f"Expected '{pattern}' in ignore_patterns, "
|
||||
f"got {context.context_config.ignore_patterns}"
|
||||
)
|
||||
|
||||
|
||||
@then('the context include_patterns should contain "{pattern}"')
|
||||
def step_check_include_pattern(context: Context, pattern: str) -> None:
|
||||
"""Verify include_patterns contains expected pattern."""
|
||||
assert context.context_config.include_patterns is not None, (
|
||||
"include_patterns is None"
|
||||
)
|
||||
assert pattern in context.context_config.include_patterns, (
|
||||
f"Expected '{pattern}' in include_patterns, "
|
||||
f"got {context.context_config.include_patterns}"
|
||||
)
|
||||
|
||||
|
||||
@then('the context ignore_patterns should not contain duplicate "{pattern}"')
|
||||
def step_check_no_duplicate_ignore_pattern(context: Context, pattern: str) -> None:
|
||||
"""Verify ignore_patterns does not contain duplicates of the given pattern."""
|
||||
count = context.context_config.ignore_patterns.count(pattern)
|
||||
assert count == 1, (
|
||||
f"Expected exactly 1 occurrence of '{pattern}' in ignore_patterns, "
|
||||
f"found {count}: {context.context_config.ignore_patterns}"
|
||||
)
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Step definitions for Project domain model extension tests (B1.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from pydantic import ValidationError
|
||||
|
||||
from cleveragents.domain.models.core.project import Project
|
||||
from cleveragents.domain.models.core.resource import (
|
||||
Resource,
|
||||
ResourceType,
|
||||
)
|
||||
|
||||
VALID_ULID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
VALID_ULID_2 = "01ARZ3NDEKTSV4RRFFQ69G5FAW"
|
||||
VALID_ULID_3 = "01ARZ3NDEKTSV4RRFFQ69G5FAX"
|
||||
|
||||
|
||||
def _make_resource(
|
||||
name: str,
|
||||
is_remote: bool = False,
|
||||
resource_id: str = VALID_ULID_2,
|
||||
) -> Resource:
|
||||
"""Helper to create a test Resource."""
|
||||
return Resource(
|
||||
resource_id=resource_id,
|
||||
name=name,
|
||||
type=ResourceType.GIT_REPOSITORY,
|
||||
location="/tmp/repo" if not is_remote else "https://github.com/test",
|
||||
is_remote=is_remote,
|
||||
)
|
||||
|
||||
|
||||
# Project creation steps
|
||||
|
||||
|
||||
@given('a Project with project_id "{pid}" name "{name}" namespace "{namespace}"')
|
||||
def step_create_project_with_namespace(
|
||||
context: Context, pid: str, name: str, namespace: str
|
||||
) -> None:
|
||||
"""Create a Project with explicit namespace."""
|
||||
context.project = Project(
|
||||
project_id=pid,
|
||||
name=name,
|
||||
namespace=namespace,
|
||||
path=Path("/tmp/test"),
|
||||
)
|
||||
|
||||
|
||||
@given('a Project with project_id "{pid}" and name "{name}"')
|
||||
def step_create_project_minimal(context: Context, pid: str, name: str) -> None:
|
||||
"""Create a Project with minimal fields."""
|
||||
context.project = Project(
|
||||
project_id=pid,
|
||||
name=name,
|
||||
path=Path("/tmp/test"),
|
||||
)
|
||||
|
||||
|
||||
@given('a Project with project_id "{pid}" name "{name}" and description "{desc}"')
|
||||
def step_create_project_with_description(
|
||||
context: Context, pid: str, name: str, desc: str
|
||||
) -> None:
|
||||
"""Create a Project with description."""
|
||||
context.project = Project(
|
||||
project_id=pid,
|
||||
name=name,
|
||||
description=desc,
|
||||
path=Path("/tmp/test"),
|
||||
)
|
||||
|
||||
|
||||
@given('a Project with project_id "{pid}" name "{name}" and tags "{tags_str}"')
|
||||
def step_create_project_with_tags(
|
||||
context: Context, pid: str, name: str, tags_str: str
|
||||
) -> None:
|
||||
"""Create a Project with tags."""
|
||||
tags = [t.strip() for t in tags_str.split(",")]
|
||||
context.project = Project(
|
||||
project_id=pid,
|
||||
name=name,
|
||||
tags=tags,
|
||||
path=Path("/tmp/test"),
|
||||
)
|
||||
|
||||
|
||||
@given('a Project with project_id "{pid}" name "{name}" and all remote resources')
|
||||
def step_create_project_all_remote(context: Context, pid: str, name: str) -> None:
|
||||
"""Create a Project where all resources are remote."""
|
||||
resources = [
|
||||
_make_resource("api-svc", is_remote=True, resource_id=VALID_ULID_2),
|
||||
_make_resource("cloud-db", is_remote=True, resource_id=VALID_ULID_3),
|
||||
]
|
||||
context.project = Project(
|
||||
project_id=pid,
|
||||
name=name,
|
||||
resources=resources,
|
||||
path=Path("/tmp/test"),
|
||||
)
|
||||
|
||||
|
||||
@given('a Project with project_id "{pid}" name "{name}" and mixed resources')
|
||||
def step_create_project_mixed_resources(context: Context, pid: str, name: str) -> None:
|
||||
"""Create a Project with both local and remote resources."""
|
||||
resources = [
|
||||
_make_resource("local-repo", is_remote=False, resource_id=VALID_ULID_2),
|
||||
_make_resource("api-svc", is_remote=True, resource_id=VALID_ULID_3),
|
||||
]
|
||||
context.project = Project(
|
||||
project_id=pid,
|
||||
name=name,
|
||||
resources=resources,
|
||||
path=Path("/tmp/test"),
|
||||
)
|
||||
|
||||
|
||||
@given('a Project with project_id "{pid}" name "{name}" and a resource named "{rname}"')
|
||||
def step_create_project_with_resource(
|
||||
context: Context, pid: str, name: str, rname: str
|
||||
) -> None:
|
||||
"""Create a Project with one resource."""
|
||||
resource = _make_resource(rname)
|
||||
context.project = Project(
|
||||
project_id=pid,
|
||||
name=name,
|
||||
resources=[resource],
|
||||
path=Path("/tmp/test"),
|
||||
)
|
||||
|
||||
|
||||
# Namespace validation steps
|
||||
|
||||
|
||||
@when('I try to create a Project with namespace "{namespace}"')
|
||||
def step_try_create_project_bad_namespace(context: Context, namespace: str) -> None:
|
||||
"""Attempt to create a Project with an invalid namespace."""
|
||||
context.error = None
|
||||
try:
|
||||
context.project = Project(
|
||||
project_id=VALID_ULID,
|
||||
name="test-project",
|
||||
namespace=namespace,
|
||||
path=Path("/tmp/test"),
|
||||
)
|
||||
except (ValidationError, ValueError) as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@then("a namespace validation error should be raised")
|
||||
def step_check_namespace_error(context: Context) -> None:
|
||||
"""Verify a namespace validation error was raised."""
|
||||
assert context.error is not None, (
|
||||
"Expected a namespace validation error but none was raised"
|
||||
)
|
||||
|
||||
|
||||
# Resource manipulation steps
|
||||
|
||||
|
||||
@when('I add a resource named "{rname}" to the project')
|
||||
def step_add_resource(context: Context, rname: str) -> None:
|
||||
"""Add a resource to the project."""
|
||||
resource = _make_resource(rname)
|
||||
context.project = context.project.add_resource(resource)
|
||||
|
||||
|
||||
@when('I remove the resource named "{rname}" from the project')
|
||||
def step_remove_resource(context: Context, rname: str) -> None:
|
||||
"""Remove a resource from the project."""
|
||||
context.project = context.project.remove_resource(rname)
|
||||
|
||||
|
||||
@when('I get the resource named "{rname}" from the project')
|
||||
def step_get_resource(context: Context, rname: str) -> None:
|
||||
"""Get a resource by name from the project."""
|
||||
context.retrieved_resource = context.project.get_resource(rname)
|
||||
|
||||
|
||||
# Project field assertions
|
||||
|
||||
|
||||
@then('the project project_id should be "{expected}"')
|
||||
def step_check_project_id(context: Context, expected: str) -> None:
|
||||
"""Verify project_id."""
|
||||
assert context.project.project_id == expected
|
||||
|
||||
|
||||
# Note: "the project name should be" step is defined in service_steps.py
|
||||
|
||||
|
||||
@then('the project namespace should be "{expected}"')
|
||||
def step_check_project_namespace(context: Context, expected: str) -> None:
|
||||
"""Verify project namespace."""
|
||||
assert context.project.namespace == expected
|
||||
|
||||
|
||||
@then("the project description should be none")
|
||||
def step_check_project_description_none(context: Context) -> None:
|
||||
"""Verify description is None."""
|
||||
assert context.project.description is None
|
||||
|
||||
|
||||
@then('the project description should be "{expected}"')
|
||||
def step_check_project_description(context: Context, expected: str) -> None:
|
||||
"""Verify description value."""
|
||||
assert context.project.description == expected
|
||||
|
||||
|
||||
@then("the project tags should be empty")
|
||||
def step_check_project_tags_empty(context: Context) -> None:
|
||||
"""Verify tags is empty list."""
|
||||
assert context.project.tags == []
|
||||
|
||||
|
||||
@then('the project tags should contain "{tag}"')
|
||||
def step_check_project_tag(context: Context, tag: str) -> None:
|
||||
"""Verify tags contains expected value."""
|
||||
assert tag in context.project.tags
|
||||
|
||||
|
||||
@then("the project resources should be empty")
|
||||
def step_check_project_resources_empty(context: Context) -> None:
|
||||
"""Verify resources is empty list."""
|
||||
assert context.project.resources == []
|
||||
|
||||
|
||||
@then("the project validation_config should be none")
|
||||
def step_check_project_validation_config_none(context: Context) -> None:
|
||||
"""Verify validation_config is None."""
|
||||
assert context.project.validation_config is None
|
||||
|
||||
|
||||
@then("the project context_config should not be none")
|
||||
def step_check_project_context_config_not_none(context: Context) -> None:
|
||||
"""Verify context_config is set."""
|
||||
assert context.project.context_config is not None
|
||||
|
||||
|
||||
@then("the project created_at should be set")
|
||||
def step_check_project_created_at(context: Context) -> None:
|
||||
"""Verify created_at is set."""
|
||||
assert context.project.created_at is not None
|
||||
|
||||
|
||||
@then("the project updated_at should be set")
|
||||
def step_check_project_updated_at(context: Context) -> None:
|
||||
"""Verify updated_at is set."""
|
||||
assert context.project.updated_at is not None
|
||||
|
||||
|
||||
@then("the project is_remote should be false")
|
||||
def step_check_project_not_remote(context: Context) -> None:
|
||||
"""Verify project is not remote."""
|
||||
assert context.project.is_remote is False
|
||||
|
||||
|
||||
@then("the project is_remote should be true")
|
||||
def step_check_project_is_remote(context: Context) -> None:
|
||||
"""Verify project is remote."""
|
||||
assert context.project.is_remote is True
|
||||
|
||||
|
||||
@then('the project namespaced_name should be "{expected}"')
|
||||
def step_check_project_namespaced_name(context: Context, expected: str) -> None:
|
||||
"""Verify namespaced_name property."""
|
||||
assert context.project.namespaced_name == expected
|
||||
|
||||
|
||||
@then("the project should have {count:d} resource")
|
||||
def step_check_project_resource_count(context: Context, count: int) -> None:
|
||||
"""Verify resource count."""
|
||||
assert len(context.project.resources) == count
|
||||
|
||||
|
||||
@then('the project should have a resource named "{rname}"')
|
||||
def step_check_project_has_resource(context: Context, rname: str) -> None:
|
||||
"""Verify project has a resource with the given name."""
|
||||
found = context.project.get_resource(rname)
|
||||
assert found is not None, f"Expected resource '{rname}' not found"
|
||||
|
||||
|
||||
@then('the retrieved resource name should be "{expected}"')
|
||||
def step_check_retrieved_resource_name(context: Context, expected: str) -> None:
|
||||
"""Verify retrieved resource name."""
|
||||
assert context.retrieved_resource is not None
|
||||
assert context.retrieved_resource.name == expected
|
||||
|
||||
|
||||
@then("the retrieved resource should be none")
|
||||
def step_check_retrieved_resource_none(context: Context) -> None:
|
||||
"""Verify retrieved resource is None."""
|
||||
assert context.retrieved_resource is None
|
||||
|
||||
|
||||
# parse_namespaced_name steps
|
||||
|
||||
|
||||
@when('I parse the project namespaced name "{namespaced}"')
|
||||
def step_parse_project_namespaced_name(context: Context, namespaced: str) -> None:
|
||||
"""Parse a namespaced name string via Project.parse_namespaced_name."""
|
||||
namespace, name = Project.parse_namespaced_name(namespaced)
|
||||
context.parsed_project_namespace = namespace
|
||||
context.parsed_project_name = name
|
||||
|
||||
|
||||
@when('I try to parse the project namespaced name "{namespaced}"')
|
||||
def step_try_parse_project_namespaced_name(context: Context, namespaced: str) -> None:
|
||||
"""Attempt to parse an invalid namespaced name string."""
|
||||
context.error = None
|
||||
try:
|
||||
Project.parse_namespaced_name(namespaced)
|
||||
except ValueError as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@then('the parsed project namespace should be "{expected}"')
|
||||
def step_check_parsed_project_namespace(context: Context, expected: str) -> None:
|
||||
"""Verify parsed project namespace."""
|
||||
assert context.parsed_project_namespace == expected
|
||||
|
||||
|
||||
@then('the parsed project name should be "{expected}"')
|
||||
def step_check_parsed_project_name(context: Context, expected: str) -> None:
|
||||
"""Verify parsed project name."""
|
||||
assert context.parsed_project_name == expected
|
||||
|
||||
|
||||
@then("a project namespaced name parse error should be raised")
|
||||
def step_check_project_namespaced_name_error(context: Context) -> None:
|
||||
"""Verify a namespaced name parse error was raised."""
|
||||
assert context.error is not None, (
|
||||
"Expected a ValueError for invalid namespaced name but none was raised"
|
||||
)
|
||||
|
||||
|
||||
# Name validation steps
|
||||
|
||||
|
||||
@when('I try to create a Project with invalid name "{name}"')
|
||||
def step_try_create_project_bad_name(context: Context, name: str) -> None:
|
||||
"""Attempt to create a Project with an invalid name."""
|
||||
context.error = None
|
||||
try:
|
||||
context.project = Project(
|
||||
project_id=VALID_ULID,
|
||||
name=name,
|
||||
path=Path("/tmp/test"),
|
||||
)
|
||||
except (ValidationError, ValueError) as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@then("a name validation error should be raised")
|
||||
def step_check_name_error(context: Context) -> None:
|
||||
"""Verify a name validation error was raised."""
|
||||
assert context.error is not None, (
|
||||
"Expected a name validation error but none was raised"
|
||||
)
|
||||
@@ -0,0 +1,558 @@
|
||||
"""Step definitions for Resource domain model tests (B1.2, B1.3, B1.4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from pydantic import ValidationError
|
||||
|
||||
from cleveragents.domain.models.core.resource import (
|
||||
Resource,
|
||||
ResourceType,
|
||||
SandboxStrategy,
|
||||
)
|
||||
|
||||
# ResourceType Steps
|
||||
|
||||
|
||||
@then('the resource types should be "{expected_order}"')
|
||||
def step_check_resource_type_values(context: Context, expected_order: str) -> None:
|
||||
"""Verify that ResourceType has all expected values in order."""
|
||||
expected = [v.strip() for v in expected_order.split(",")]
|
||||
actual = [rt.value for rt in ResourceType]
|
||||
assert actual == expected, f"Expected resource types {expected}, got {actual}"
|
||||
|
||||
|
||||
@when("I access ResourceType.GIT_REPOSITORY")
|
||||
def step_access_git_repository(context: Context) -> None:
|
||||
"""Access the GIT_REPOSITORY enum member."""
|
||||
context.resource_type = ResourceType.GIT_REPOSITORY
|
||||
|
||||
|
||||
@when("I access ResourceType.FILESYSTEM")
|
||||
def step_access_filesystem(context: Context) -> None:
|
||||
"""Access the FILESYSTEM enum member."""
|
||||
context.resource_type = ResourceType.FILESYSTEM
|
||||
|
||||
|
||||
@when("I access ResourceType.DATABASE")
|
||||
def step_access_database(context: Context) -> None:
|
||||
"""Access the DATABASE enum member."""
|
||||
context.resource_type = ResourceType.DATABASE
|
||||
|
||||
|
||||
@when("I access ResourceType.API_ENDPOINT")
|
||||
def step_access_api_endpoint(context: Context) -> None:
|
||||
"""Access the API_ENDPOINT enum member."""
|
||||
context.resource_type = ResourceType.API_ENDPOINT
|
||||
|
||||
|
||||
@when("I access ResourceType.DOCUMENT_CORPUS")
|
||||
def step_access_document_corpus(context: Context) -> None:
|
||||
"""Access the DOCUMENT_CORPUS enum member."""
|
||||
context.resource_type = ResourceType.DOCUMENT_CORPUS
|
||||
|
||||
|
||||
@when("I access ResourceType.CLOUD_INFRASTRUCTURE")
|
||||
def step_access_cloud_infrastructure(context: Context) -> None:
|
||||
"""Access the CLOUD_INFRASTRUCTURE enum member."""
|
||||
context.resource_type = ResourceType.CLOUD_INFRASTRUCTURE
|
||||
|
||||
|
||||
@then('the resource type value should be "{expected}"')
|
||||
def step_check_resource_type_value(context: Context, expected: str) -> None:
|
||||
"""Check the resource type value matches expected."""
|
||||
actual = context.resource_type.value
|
||||
assert actual == expected, f"Expected value '{expected}', got '{actual}'"
|
||||
|
||||
|
||||
@then("the resource type should be a string")
|
||||
def step_check_resource_type_is_string(context: Context) -> None:
|
||||
"""Verify ResourceType inherits from str."""
|
||||
assert isinstance(context.resource_type, str), (
|
||||
f"Expected ResourceType to be a string, got {type(context.resource_type)}"
|
||||
)
|
||||
|
||||
|
||||
@when('I create a ResourceType from string "{value}"')
|
||||
def step_create_resource_type_from_string(context: Context, value: str) -> None:
|
||||
"""Create a ResourceType from a string value."""
|
||||
context.error = None
|
||||
try:
|
||||
context.resource_type = ResourceType(value)
|
||||
except ValueError as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when('I try to create a ResourceType from string "{value}"')
|
||||
def step_try_create_resource_type_from_string(context: Context, value: str) -> None:
|
||||
"""Attempt to create a ResourceType from an invalid string."""
|
||||
context.error = None
|
||||
try:
|
||||
context.resource_type = ResourceType(value)
|
||||
except ValueError as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@then("a ValueError should be raised")
|
||||
def step_check_value_error_raised(context: Context) -> None:
|
||||
"""Verify a ValueError was captured."""
|
||||
assert context.error is not None, "Expected a ValueError but none was raised"
|
||||
assert isinstance(context.error, ValueError), (
|
||||
f"Expected ValueError, got {type(context.error).__name__}"
|
||||
)
|
||||
|
||||
|
||||
# SandboxStrategy Steps
|
||||
|
||||
|
||||
@then('the sandbox strategies should be "{expected_order}"')
|
||||
def step_check_sandbox_strategy_values(context: Context, expected_order: str) -> None:
|
||||
"""Verify that SandboxStrategy has all expected values in order."""
|
||||
expected = [v.strip() for v in expected_order.split(",")]
|
||||
actual = [ss.value for ss in SandboxStrategy]
|
||||
assert actual == expected, f"Expected sandbox strategies {expected}, got {actual}"
|
||||
|
||||
|
||||
@when("I access SandboxStrategy.GIT_WORKTREE")
|
||||
def step_access_git_worktree(context: Context) -> None:
|
||||
"""Access the GIT_WORKTREE enum member."""
|
||||
context.sandbox_strategy = SandboxStrategy.GIT_WORKTREE
|
||||
|
||||
|
||||
@when("I access SandboxStrategy.COPY_ON_WRITE")
|
||||
def step_access_copy_on_write(context: Context) -> None:
|
||||
"""Access the COPY_ON_WRITE enum member."""
|
||||
context.sandbox_strategy = SandboxStrategy.COPY_ON_WRITE
|
||||
|
||||
|
||||
@when("I access SandboxStrategy.OVERLAY")
|
||||
def step_access_overlay(context: Context) -> None:
|
||||
"""Access the OVERLAY enum member."""
|
||||
context.sandbox_strategy = SandboxStrategy.OVERLAY
|
||||
|
||||
|
||||
@when("I access SandboxStrategy.TRANSACTION_ROLLBACK")
|
||||
def step_access_transaction_rollback(context: Context) -> None:
|
||||
"""Access the TRANSACTION_ROLLBACK enum member."""
|
||||
context.sandbox_strategy = SandboxStrategy.TRANSACTION_ROLLBACK
|
||||
|
||||
|
||||
@when("I access SandboxStrategy.VERSIONING")
|
||||
def step_access_versioning(context: Context) -> None:
|
||||
"""Access the VERSIONING enum member."""
|
||||
context.sandbox_strategy = SandboxStrategy.VERSIONING
|
||||
|
||||
|
||||
@when("I access SandboxStrategy.NONE")
|
||||
def step_access_none_strategy(context: Context) -> None:
|
||||
"""Access the NONE enum member."""
|
||||
context.sandbox_strategy = SandboxStrategy.NONE
|
||||
|
||||
|
||||
@then('the sandbox strategy value should be "{expected}"')
|
||||
def step_check_sandbox_strategy_value(context: Context, expected: str) -> None:
|
||||
"""Check the sandbox strategy value matches expected."""
|
||||
actual = context.sandbox_strategy.value
|
||||
assert actual == expected, f"Expected value '{expected}', got '{actual}'"
|
||||
|
||||
|
||||
@then("the sandbox strategy should be a string")
|
||||
def step_check_sandbox_strategy_is_string(context: Context) -> None:
|
||||
"""Verify SandboxStrategy inherits from str."""
|
||||
assert isinstance(context.sandbox_strategy, str), (
|
||||
f"Expected SandboxStrategy to be a string, got {type(context.sandbox_strategy)}"
|
||||
)
|
||||
|
||||
|
||||
# SandboxStrategy.supports_rollback Tests
|
||||
|
||||
|
||||
@then("SandboxStrategy.GIT_WORKTREE should support rollback")
|
||||
def step_git_worktree_supports_rollback(context: Context) -> None:
|
||||
"""Verify GIT_WORKTREE supports rollback."""
|
||||
assert SandboxStrategy.GIT_WORKTREE.supports_rollback is True
|
||||
|
||||
|
||||
@then("SandboxStrategy.COPY_ON_WRITE should support rollback")
|
||||
def step_copy_on_write_supports_rollback(context: Context) -> None:
|
||||
"""Verify COPY_ON_WRITE supports rollback."""
|
||||
assert SandboxStrategy.COPY_ON_WRITE.supports_rollback is True
|
||||
|
||||
|
||||
@then("SandboxStrategy.OVERLAY should support rollback")
|
||||
def step_overlay_supports_rollback(context: Context) -> None:
|
||||
"""Verify OVERLAY supports rollback."""
|
||||
assert SandboxStrategy.OVERLAY.supports_rollback is True
|
||||
|
||||
|
||||
@then("SandboxStrategy.TRANSACTION_ROLLBACK should support rollback")
|
||||
def step_transaction_rollback_supports_rollback(context: Context) -> None:
|
||||
"""Verify TRANSACTION_ROLLBACK supports rollback."""
|
||||
assert SandboxStrategy.TRANSACTION_ROLLBACK.supports_rollback is True
|
||||
|
||||
|
||||
@then("SandboxStrategy.VERSIONING should support rollback")
|
||||
def step_versioning_supports_rollback(context: Context) -> None:
|
||||
"""Verify VERSIONING supports rollback."""
|
||||
assert SandboxStrategy.VERSIONING.supports_rollback is True
|
||||
|
||||
|
||||
@then("SandboxStrategy.NONE should not support rollback")
|
||||
def step_none_does_not_support_rollback(context: Context) -> None:
|
||||
"""Verify NONE does not support rollback."""
|
||||
assert SandboxStrategy.NONE.supports_rollback is False
|
||||
|
||||
|
||||
# SandboxStrategy.is_copy_based Tests
|
||||
|
||||
|
||||
@then("SandboxStrategy.COPY_ON_WRITE should be copy based")
|
||||
def step_copy_on_write_is_copy_based(context: Context) -> None:
|
||||
"""Verify COPY_ON_WRITE is copy based."""
|
||||
assert SandboxStrategy.COPY_ON_WRITE.is_copy_based is True
|
||||
|
||||
|
||||
@then("SandboxStrategy.OVERLAY should be copy based")
|
||||
def step_overlay_is_copy_based(context: Context) -> None:
|
||||
"""Verify OVERLAY is copy based."""
|
||||
assert SandboxStrategy.OVERLAY.is_copy_based is True
|
||||
|
||||
|
||||
@then("SandboxStrategy.GIT_WORKTREE should not be copy based")
|
||||
def step_git_worktree_not_copy_based(context: Context) -> None:
|
||||
"""Verify GIT_WORKTREE is not copy based."""
|
||||
assert SandboxStrategy.GIT_WORKTREE.is_copy_based is False
|
||||
|
||||
|
||||
@then("SandboxStrategy.TRANSACTION_ROLLBACK should not be copy based")
|
||||
def step_transaction_rollback_not_copy_based(context: Context) -> None:
|
||||
"""Verify TRANSACTION_ROLLBACK is not copy based."""
|
||||
assert SandboxStrategy.TRANSACTION_ROLLBACK.is_copy_based is False
|
||||
|
||||
|
||||
@then("SandboxStrategy.VERSIONING should not be copy based")
|
||||
def step_versioning_not_copy_based(context: Context) -> None:
|
||||
"""Verify VERSIONING is not copy based."""
|
||||
assert SandboxStrategy.VERSIONING.is_copy_based is False
|
||||
|
||||
|
||||
@then("SandboxStrategy.NONE should not be copy based")
|
||||
def step_none_not_copy_based(context: Context) -> None:
|
||||
"""Verify NONE is not copy based."""
|
||||
assert SandboxStrategy.NONE.is_copy_based is False
|
||||
|
||||
|
||||
# Resource Pydantic Model Steps (B1.2)
|
||||
|
||||
VALID_ULID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
|
||||
|
||||
@given(
|
||||
'a Resource with required fields id "{resource_id}" name "{name}" type "{rtype}" location "{location}"'
|
||||
)
|
||||
def step_create_resource(
|
||||
context: Context, resource_id: str, name: str, rtype: str, location: str
|
||||
) -> None:
|
||||
"""Create a Resource with required fields only."""
|
||||
context.error = None
|
||||
try:
|
||||
context.resource = Resource(
|
||||
resource_id=resource_id,
|
||||
name=name,
|
||||
type=ResourceType(rtype),
|
||||
location=location,
|
||||
)
|
||||
except (ValidationError, ValueError) as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@given("I have a valid Resource")
|
||||
def step_have_valid_resource(context: Context) -> None:
|
||||
"""Create a valid Resource for mutation testing."""
|
||||
context.resource = Resource(
|
||||
resource_id=VALID_ULID,
|
||||
name="my-repo",
|
||||
type=ResourceType.GIT_REPOSITORY,
|
||||
location="/tmp/repo",
|
||||
)
|
||||
|
||||
|
||||
@when("I try to modify the resource name")
|
||||
def step_try_modify_resource_name(context: Context) -> None:
|
||||
"""Attempt to modify a frozen model field."""
|
||||
context.error = None
|
||||
try:
|
||||
context.resource.name = "new-name" # type: ignore[misc]
|
||||
except ValidationError as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@given(
|
||||
'a remote Resource with id "{resource_id}" name "{name}" type "{rtype}" location "{location}"'
|
||||
)
|
||||
def step_create_resource_remote(
|
||||
context: Context, resource_id: str, name: str, rtype: str, location: str
|
||||
) -> None:
|
||||
"""Create a remote Resource."""
|
||||
context.error = None
|
||||
try:
|
||||
context.resource = Resource(
|
||||
resource_id=resource_id,
|
||||
name=name,
|
||||
type=ResourceType(rtype),
|
||||
location=location,
|
||||
is_remote=True,
|
||||
)
|
||||
except (ValidationError, ValueError) as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@given(
|
||||
'a read-only Resource with id "{resource_id}" name "{name}" type "{rtype}" location "{location}"'
|
||||
)
|
||||
def step_create_resource_read_only(
|
||||
context: Context, resource_id: str, name: str, rtype: str, location: str
|
||||
) -> None:
|
||||
"""Create a read-only Resource."""
|
||||
context.error = None
|
||||
try:
|
||||
context.resource = Resource(
|
||||
resource_id=resource_id,
|
||||
name=name,
|
||||
type=ResourceType(rtype),
|
||||
location=location,
|
||||
read_only=True,
|
||||
)
|
||||
except (ValidationError, ValueError) as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@given(
|
||||
'a Resource with id "{resource_id}" name "{name}" type "{rtype}" location "{location}" strategy "{strategy}"'
|
||||
)
|
||||
def step_create_resource_with_strategy(
|
||||
context: Context,
|
||||
resource_id: str,
|
||||
name: str,
|
||||
rtype: str,
|
||||
location: str,
|
||||
strategy: str,
|
||||
) -> None:
|
||||
"""Create a Resource with a specific sandbox strategy."""
|
||||
context.error = None
|
||||
try:
|
||||
context.resource = Resource(
|
||||
resource_id=resource_id,
|
||||
name=name,
|
||||
type=ResourceType(rtype),
|
||||
location=location,
|
||||
sandbox_strategy=SandboxStrategy(strategy),
|
||||
)
|
||||
except (ValidationError, ValueError) as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@given(
|
||||
'a Resource with id "{resource_id}" name "{name}" type "{rtype}" location "{location}" metadata "{meta_str}"'
|
||||
)
|
||||
def step_create_resource_with_metadata(
|
||||
context: Context,
|
||||
resource_id: str,
|
||||
name: str,
|
||||
rtype: str,
|
||||
location: str,
|
||||
meta_str: str,
|
||||
) -> None:
|
||||
"""Create a Resource with metadata parsed from key=value pairs."""
|
||||
metadata = {}
|
||||
for pair in meta_str.split(","):
|
||||
key, value = pair.strip().split("=", 1)
|
||||
metadata[key.strip()] = value.strip()
|
||||
context.error = None
|
||||
try:
|
||||
context.resource = Resource(
|
||||
resource_id=resource_id,
|
||||
name=name,
|
||||
type=ResourceType(rtype),
|
||||
location=location,
|
||||
metadata=metadata,
|
||||
)
|
||||
except (ValidationError, ValueError) as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when(
|
||||
'I try to create a Resource with id "{resource_id}" name "{name}" type "{rtype}" location "{location}"'
|
||||
)
|
||||
def step_try_create_resource(
|
||||
context: Context, resource_id: str, name: str, rtype: str, location: str
|
||||
) -> None:
|
||||
"""Attempt to create a Resource that may fail validation."""
|
||||
context.error = None
|
||||
try:
|
||||
context.resource = Resource(
|
||||
resource_id=resource_id,
|
||||
name=name,
|
||||
type=ResourceType(rtype),
|
||||
location=location,
|
||||
)
|
||||
except (ValidationError, ValueError) as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when("I try to create a Resource with an empty name")
|
||||
def step_try_create_resource_empty_name(context: Context) -> None:
|
||||
"""Attempt to create a Resource with empty name."""
|
||||
context.error = None
|
||||
try:
|
||||
context.resource = Resource(
|
||||
resource_id=VALID_ULID,
|
||||
name="",
|
||||
type=ResourceType.GIT_REPOSITORY,
|
||||
location="/tmp/repo",
|
||||
)
|
||||
except (ValidationError, ValueError) as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when("I try to create a Resource with an empty location")
|
||||
def step_try_create_resource_empty_location(context: Context) -> None:
|
||||
"""Attempt to create a Resource with empty location."""
|
||||
context.error = None
|
||||
try:
|
||||
context.resource = Resource(
|
||||
resource_id=VALID_ULID,
|
||||
name="my-repo",
|
||||
type=ResourceType.GIT_REPOSITORY,
|
||||
location="",
|
||||
)
|
||||
except (ValidationError, ValueError) as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
# Resource field assertion steps
|
||||
|
||||
|
||||
@then('the resource name should be "{expected}"')
|
||||
def step_check_resource_name(context: Context, expected: str) -> None:
|
||||
"""Verify resource name."""
|
||||
assert context.resource.name == expected, (
|
||||
f"Expected name '{expected}', got '{context.resource.name}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the resource type should be ResourceType.GIT_REPOSITORY")
|
||||
def step_check_resource_type_git(context: Context) -> None:
|
||||
"""Verify resource type is GIT_REPOSITORY."""
|
||||
assert context.resource.type == ResourceType.GIT_REPOSITORY
|
||||
|
||||
|
||||
@then('the resource location should be "{expected}"')
|
||||
def step_check_resource_location(context: Context, expected: str) -> None:
|
||||
"""Verify resource location."""
|
||||
assert context.resource.location == expected, (
|
||||
f"Expected location '{expected}', got '{context.resource.location}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the resource is_remote should be false")
|
||||
def step_check_resource_not_remote(context: Context) -> None:
|
||||
"""Verify resource is not remote."""
|
||||
assert context.resource.is_remote is False
|
||||
|
||||
|
||||
@then("the resource is_remote should be true")
|
||||
def step_check_resource_is_remote(context: Context) -> None:
|
||||
"""Verify resource is remote."""
|
||||
assert context.resource.is_remote is True
|
||||
|
||||
|
||||
@then("the resource sandbox_strategy should be SandboxStrategy.NONE")
|
||||
def step_check_resource_strategy_none(context: Context) -> None:
|
||||
"""Verify default sandbox strategy is NONE."""
|
||||
assert context.resource.sandbox_strategy == SandboxStrategy.NONE
|
||||
|
||||
|
||||
@then("the resource sandbox_strategy should be SandboxStrategy.GIT_WORKTREE")
|
||||
def step_check_resource_strategy_worktree(context: Context) -> None:
|
||||
"""Verify sandbox strategy is GIT_WORKTREE."""
|
||||
assert context.resource.sandbox_strategy == SandboxStrategy.GIT_WORKTREE
|
||||
|
||||
|
||||
@then("the resource read_only should be false")
|
||||
def step_check_resource_not_readonly(context: Context) -> None:
|
||||
"""Verify resource is not read-only."""
|
||||
assert context.resource.read_only is False
|
||||
|
||||
|
||||
@then("the resource read_only should be true")
|
||||
def step_check_resource_is_readonly(context: Context) -> None:
|
||||
"""Verify resource is read-only."""
|
||||
assert context.resource.read_only is True
|
||||
|
||||
|
||||
@then("the resource metadata should be empty")
|
||||
def step_check_resource_metadata_empty(context: Context) -> None:
|
||||
"""Verify resource metadata is empty dict."""
|
||||
assert context.resource.metadata == {}, (
|
||||
f"Expected empty metadata, got {context.resource.metadata}"
|
||||
)
|
||||
|
||||
|
||||
@then("the resource created_at should be set")
|
||||
def step_check_resource_created_at(context: Context) -> None:
|
||||
"""Verify resource has a created_at timestamp."""
|
||||
assert context.resource.created_at is not None
|
||||
|
||||
|
||||
@then('the resource metadata should contain key "{key}" with value "{value}"')
|
||||
def step_check_resource_metadata_key(context: Context, key: str, value: str) -> None:
|
||||
"""Verify resource metadata contains expected key-value pair."""
|
||||
assert key in context.resource.metadata, (
|
||||
f"Expected key '{key}' in metadata, got {context.resource.metadata}"
|
||||
)
|
||||
assert context.resource.metadata[key] == value, (
|
||||
f"Expected metadata['{key}'] = '{value}', got '{context.resource.metadata[key]}'"
|
||||
)
|
||||
|
||||
|
||||
# Note: "a validation error should be raised" step is defined in domain_models_steps.py
|
||||
|
||||
|
||||
# Resource property assertion steps
|
||||
|
||||
|
||||
@then("the resource supports_sandbox should be true")
|
||||
def step_check_supports_sandbox_true(context: Context) -> None:
|
||||
"""Verify resource supports sandbox."""
|
||||
assert context.resource.supports_sandbox is True
|
||||
|
||||
|
||||
@then("the resource supports_sandbox should be false")
|
||||
def step_check_supports_sandbox_false(context: Context) -> None:
|
||||
"""Verify resource does not support sandbox."""
|
||||
assert context.resource.supports_sandbox is False
|
||||
|
||||
|
||||
@then("the resource can_write should be true")
|
||||
def step_check_can_write_true(context: Context) -> None:
|
||||
"""Verify resource can be written to."""
|
||||
assert context.resource.can_write is True
|
||||
|
||||
|
||||
@then("the resource can_write should be false")
|
||||
def step_check_can_write_false(context: Context) -> None:
|
||||
"""Verify resource cannot be written to."""
|
||||
assert context.resource.can_write is False
|
||||
|
||||
|
||||
@then('the resource get_sandbox_path with base "{base}" should end with "{suffix}"')
|
||||
def step_check_sandbox_path(context: Context, base: str, suffix: str) -> None:
|
||||
"""Verify get_sandbox_path returns expected path."""
|
||||
result = context.resource.get_sandbox_path(Path(base))
|
||||
assert str(result).endswith(suffix), (
|
||||
f"Expected path ending with '{suffix}', got '{result}'"
|
||||
)
|
||||
@@ -50,9 +50,16 @@ from cleveragents.domain.models.core.plan_legacy import (
|
||||
PlanStatus,
|
||||
)
|
||||
from cleveragents.domain.models.core.project import (
|
||||
ContextConfig,
|
||||
Project,
|
||||
ProjectSettings,
|
||||
ProjectStats,
|
||||
ValidationConfig,
|
||||
)
|
||||
from cleveragents.domain.models.core.resource import (
|
||||
Resource,
|
||||
ResourceType,
|
||||
SandboxStrategy,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -62,6 +69,7 @@ __all__ = [
|
||||
"ChangeSet",
|
||||
"CloudBillingFields",
|
||||
"Context",
|
||||
"ContextConfig",
|
||||
"ContextFile",
|
||||
"ContextType",
|
||||
"ContextUpdateResult",
|
||||
@@ -92,7 +100,11 @@ __all__ = [
|
||||
"ProjectLink",
|
||||
"ProjectSettings",
|
||||
"ProjectStats",
|
||||
"Resource",
|
||||
"ResourceType",
|
||||
"SandboxStrategy",
|
||||
"SummaryForUpdateContextParams",
|
||||
"User",
|
||||
"ValidationConfig",
|
||||
"can_transition",
|
||||
]
|
||||
|
||||
@@ -1,26 +1,158 @@
|
||||
"""Project domain model for CleverAgents.
|
||||
|
||||
Based on Phase 0 discovery and ADR-004 (Pydantic Validation).
|
||||
Includes ValidationConfig (B1.5), ContextConfig (B1.6), and the Project model (B1.1).
|
||||
Based on Phase 0 discovery, implementation_plan.md, and ADR-004 (Pydantic Validation).
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from cleveragents.domain.models.core.resource import Resource
|
||||
|
||||
ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$"
|
||||
NAMESPACE_PATTERN = r"^(local|[a-z][a-z0-9_]{0,49})$"
|
||||
RESERVED_NAMESPACES = frozenset({"system", "internal", "admin", "root"})
|
||||
|
||||
DEFAULT_IGNORE_PATTERNS: list[str] = [
|
||||
".git/",
|
||||
"node_modules/",
|
||||
"__pycache__/",
|
||||
".venv/",
|
||||
"*.pyc",
|
||||
".DS_Store",
|
||||
]
|
||||
|
||||
|
||||
class ValidationConfig(BaseModel):
|
||||
"""Configuration for project validation commands.
|
||||
|
||||
Defines test, lint, type-check, and build commands along with
|
||||
custom commands and execution settings.
|
||||
|
||||
Implements B1.5 from the implementation plan.
|
||||
"""
|
||||
|
||||
test_command: str | None = Field(default=None, description="Command to run tests")
|
||||
lint_command: str | None = Field(default=None, description="Command to run linting")
|
||||
type_check_command: str | None = Field(
|
||||
default=None, description="Command to run type checking"
|
||||
)
|
||||
build_command: str | None = Field(
|
||||
default=None, description="Command to build the project"
|
||||
)
|
||||
custom_commands: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="Custom named validation commands",
|
||||
)
|
||||
timeout_seconds: int = Field(
|
||||
default=300, description="Timeout for each validation command in seconds"
|
||||
)
|
||||
fail_on_lint_error: bool = Field(
|
||||
default=True, description="Whether lint errors should fail validation"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
def get_all_commands(self) -> dict[str, str]:
|
||||
"""Return all configured commands as a name->command dict.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping command names to command strings.
|
||||
Includes standard commands (test, lint, type_check, build)
|
||||
and any custom commands.
|
||||
"""
|
||||
commands: dict[str, str] = {}
|
||||
if self.test_command is not None:
|
||||
commands["test"] = self.test_command
|
||||
if self.lint_command is not None:
|
||||
commands["lint"] = self.lint_command
|
||||
if self.type_check_command is not None:
|
||||
commands["type_check"] = self.type_check_command
|
||||
if self.build_command is not None:
|
||||
commands["build"] = self.build_command
|
||||
commands.update(self.custom_commands)
|
||||
return commands
|
||||
|
||||
def has_any_validation(self) -> bool:
|
||||
"""Check if any validation commands are configured.
|
||||
|
||||
Returns:
|
||||
True if at least one command (standard or custom) is set.
|
||||
"""
|
||||
return len(self.get_all_commands()) > 0
|
||||
|
||||
|
||||
class ContextConfig(BaseModel):
|
||||
"""Configuration for project context indexing and filtering.
|
||||
|
||||
Controls how project files are indexed, chunked, and filtered
|
||||
for inclusion in AI context windows.
|
||||
|
||||
Implements B1.6 from the implementation plan.
|
||||
"""
|
||||
|
||||
ignore_patterns: list[str] = Field(
|
||||
default_factory=lambda: list(DEFAULT_IGNORE_PATTERNS),
|
||||
description="File patterns to ignore during indexing",
|
||||
)
|
||||
include_patterns: list[str] | None = Field(
|
||||
default=None, description="File patterns to include (None means include all)"
|
||||
)
|
||||
max_file_size: int = Field(
|
||||
default=1_000_000, description="Maximum file size in bytes (1MB default)"
|
||||
)
|
||||
max_files: int = Field(
|
||||
default=100_000, description="Maximum number of files to index"
|
||||
)
|
||||
indexing_strategy: str = Field(
|
||||
default="full_text",
|
||||
description="Indexing strategy: full_text, semantic, etc.",
|
||||
)
|
||||
chunking_policy: str = Field(
|
||||
default="smart", description="Chunking policy: smart, fixed, etc."
|
||||
)
|
||||
chunk_size: int = Field(default=1000, description="Chunk size in tokens")
|
||||
|
||||
@field_validator("ignore_patterns")
|
||||
@classmethod
|
||||
def merge_default_ignore_patterns(
|
||||
cls: "type[ContextConfig]", v: list[str]
|
||||
) -> list[str]:
|
||||
"""Merge user-provided ignore patterns with defaults.
|
||||
|
||||
Ensures default patterns like .git/, node_modules/, etc.
|
||||
are always included.
|
||||
"""
|
||||
merged = list(DEFAULT_IGNORE_PATTERNS)
|
||||
for pattern in v:
|
||||
if pattern not in merged:
|
||||
merged.append(pattern)
|
||||
return merged
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
class ProjectSettings(BaseModel):
|
||||
"""Project-specific settings and configuration."""
|
||||
|
||||
auto_build: bool = Field(False, description="Automatically build plans")
|
||||
auto_apply: bool = Field(False, description="Automatically apply changes")
|
||||
auto_build: bool = Field(default=False, description="Automatically build plans")
|
||||
auto_apply: bool = Field(default=False, description="Automatically apply changes")
|
||||
confirm_apply: bool = Field(
|
||||
True, description="Require confirmation before applying"
|
||||
default=True, description="Require confirmation before applying"
|
||||
)
|
||||
max_context_size: int = Field(
|
||||
52428800, description="Maximum context size in bytes (50MB)"
|
||||
default=52428800, description="Maximum context size in bytes (50MB)"
|
||||
)
|
||||
default_model: str = Field("mock-gpt", description="Default AI model")
|
||||
default_model: str = Field(default="mock-gpt", description="Default AI model")
|
||||
include_paths: list[str] = Field(
|
||||
default_factory=list, description="Relative include globs"
|
||||
)
|
||||
@@ -37,10 +169,10 @@ class ProjectSettings(BaseModel):
|
||||
class ProjectStats(BaseModel):
|
||||
"""Statistics for a project."""
|
||||
|
||||
plans: int = Field(0, ge=0)
|
||||
context_files: int = Field(0, ge=0)
|
||||
changes: int = Field(0, ge=0)
|
||||
current_plan: str | None = Field(None)
|
||||
plans: int = Field(default=0, ge=0)
|
||||
context_files: int = Field(default=0, ge=0)
|
||||
changes: int = Field(default=0, ge=0)
|
||||
current_plan: str | None = Field(default=None)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
@@ -52,19 +184,52 @@ class Project(BaseModel):
|
||||
"""Domain model for a CleverAgents project.
|
||||
|
||||
A project represents a workspace with plans, contexts, and changes.
|
||||
Projects can have resources, validation configs, and context configs.
|
||||
|
||||
Extends original model with B1.1 fields: project_id, namespace,
|
||||
description, tags, resources, validation_config, context_config.
|
||||
"""
|
||||
|
||||
id: int | None = Field(None, description="Project ID")
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
# Legacy fields (preserved for backward compatibility)
|
||||
id: int | None = Field(default=None, description="Legacy integer project ID")
|
||||
path: Path = Field(..., description="Project root path")
|
||||
settings: ProjectSettings = Field(default_factory=lambda: ProjectSettings())
|
||||
current_plan_id: int | None = Field(default=None)
|
||||
|
||||
# B1.1b - Identity fields
|
||||
project_id: str | None = Field(
|
||||
default=None, description="Unique ULID identifier", pattern=ULID_PATTERN
|
||||
)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
namespace: str = Field(
|
||||
default="local", description="Project namespace for grouping"
|
||||
)
|
||||
description: str | None = Field(
|
||||
default=None, description="Human-readable project description"
|
||||
)
|
||||
|
||||
# B1.1c - Categorization fields
|
||||
tags: list[str] = Field(
|
||||
default_factory=list, description="Project tags for filtering"
|
||||
)
|
||||
resources: list[Resource] = Field(
|
||||
default_factory=list, description="Project resource references"
|
||||
)
|
||||
validation_config: ValidationConfig | None = Field(
|
||||
default=None, description="Validation command configuration"
|
||||
)
|
||||
context_config: ContextConfig = Field(
|
||||
default_factory=lambda: ContextConfig(),
|
||||
description="Context indexing and filtering configuration",
|
||||
)
|
||||
|
||||
# B1.1d - Timestamps
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
updated_at: datetime = Field(default_factory=datetime.now)
|
||||
settings: ProjectSettings = Field(default_factory=lambda: ProjectSettings()) # type: ignore
|
||||
current_plan_id: int | None = Field(None)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls: type["Project"], v: str) -> str:
|
||||
def validate_name(cls: "type[Project]", v: str) -> str:
|
||||
"""Validate project name."""
|
||||
if not v.replace("-", "").replace("_", "").replace(" ", "").isalnum():
|
||||
raise ValueError(
|
||||
@@ -72,9 +237,31 @@ class Project(BaseModel):
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("namespace")
|
||||
@classmethod
|
||||
def validate_namespace(cls: "type[Project]", v: str) -> str:
|
||||
"""Validate namespace format and reject reserved names.
|
||||
|
||||
Namespace must match pattern: 'local' or start with lowercase letter
|
||||
followed by up to 49 lowercase alphanumeric/underscore chars.
|
||||
Reserved names (system, internal, admin, root) are rejected.
|
||||
"""
|
||||
if not re.match(NAMESPACE_PATTERN, v):
|
||||
raise ValueError(
|
||||
f"Namespace '{v}' must match pattern: 'local' or "
|
||||
"start with lowercase letter followed by lowercase "
|
||||
"alphanumeric/underscore characters (max 50 chars)"
|
||||
)
|
||||
if v in RESERVED_NAMESPACES:
|
||||
raise ValueError(
|
||||
f"Namespace '{v}' is reserved. "
|
||||
f"Reserved namespaces: {', '.join(sorted(RESERVED_NAMESPACES))}"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("path")
|
||||
@classmethod
|
||||
def validate_path(cls: type["Project"], v: Path) -> Path:
|
||||
def validate_path(cls: "type[Project]", v: Path) -> Path:
|
||||
"""Ensure path is absolute."""
|
||||
return v.resolve()
|
||||
|
||||
@@ -83,3 +270,80 @@ class Project(BaseModel):
|
||||
validate_assignment=True,
|
||||
arbitrary_types_allowed=True, # Allow Path
|
||||
)
|
||||
|
||||
# B1.1e - Computed property
|
||||
@property
|
||||
def is_remote(self) -> bool:
|
||||
"""Check if all project resources are remote.
|
||||
|
||||
A project is remote only if it has resources and ALL of them
|
||||
are remotely accessible. Empty resource lists return False.
|
||||
"""
|
||||
if not self.resources:
|
||||
return False
|
||||
return all(r.is_remote for r in self.resources)
|
||||
|
||||
# B1.1g - Helper methods
|
||||
@property
|
||||
def namespaced_name(self) -> str:
|
||||
"""Return the fully qualified namespace/name string."""
|
||||
return f"{self.namespace}/{self.name}"
|
||||
|
||||
@staticmethod
|
||||
def parse_namespaced_name(namespaced: str) -> tuple[str, str]:
|
||||
"""Parse a 'namespace/name' string into (namespace, name).
|
||||
|
||||
Args:
|
||||
namespaced: String in 'namespace/name' format.
|
||||
|
||||
Returns:
|
||||
Tuple of (namespace, name).
|
||||
|
||||
Raises:
|
||||
ValueError: If the string doesn't contain exactly one '/'.
|
||||
"""
|
||||
parts = namespaced.split("/", 1)
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"Expected 'namespace/name' format, got '{namespaced}'")
|
||||
return parts[0], parts[1]
|
||||
|
||||
def add_resource(self, resource: Resource) -> "Project":
|
||||
"""Return a new Project with the resource added.
|
||||
|
||||
Since the model may be frozen or validated, this returns
|
||||
a new instance with the resource appended.
|
||||
|
||||
Args:
|
||||
resource: The Resource to add.
|
||||
|
||||
Returns:
|
||||
New Project instance with the resource added.
|
||||
"""
|
||||
new_resources = [*list(self.resources), resource]
|
||||
return self.model_copy(update={"resources": new_resources})
|
||||
|
||||
def remove_resource(self, name: str) -> "Project":
|
||||
"""Return a new Project with the named resource removed.
|
||||
|
||||
Args:
|
||||
name: Name of the resource to remove.
|
||||
|
||||
Returns:
|
||||
New Project instance without the named resource.
|
||||
"""
|
||||
new_resources = [r for r in self.resources if r.name != name]
|
||||
return self.model_copy(update={"resources": new_resources})
|
||||
|
||||
def get_resource(self, name: str) -> Resource | None:
|
||||
"""Get a resource by name.
|
||||
|
||||
Args:
|
||||
name: Name of the resource to find.
|
||||
|
||||
Returns:
|
||||
The Resource if found, None otherwise.
|
||||
"""
|
||||
for r in self.resources:
|
||||
if r.name == name:
|
||||
return r
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Resource domain model for CleverAgents.
|
||||
|
||||
Defines ResourceType enum, SandboxStrategy enum, and the Resource
|
||||
Pydantic model for classifying project resources and determining
|
||||
sandboxing behavior.
|
||||
|
||||
Based on implementation_plan.md (B1.2, B1.3, B1.4) and ADR-004 (Pydantic Validation).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$"
|
||||
|
||||
|
||||
class ResourceType(StrEnum):
|
||||
"""Types of resources a project can reference.
|
||||
|
||||
Each resource type maps to a category of external data source
|
||||
or infrastructure that a project operates on.
|
||||
"""
|
||||
|
||||
GIT_REPOSITORY = "git_repository"
|
||||
FILESYSTEM = "filesystem"
|
||||
DATABASE = "database"
|
||||
API_ENDPOINT = "api_endpoint"
|
||||
DOCUMENT_CORPUS = "document_corpus"
|
||||
CLOUD_INFRASTRUCTURE = "cloud_infrastructure"
|
||||
|
||||
|
||||
class SandboxStrategy(StrEnum):
|
||||
"""Strategies for isolating resource modifications during plan execution.
|
||||
|
||||
Sandboxing ensures that changes made during plan execution can be
|
||||
reviewed, committed, or rolled back without affecting the original resource.
|
||||
"""
|
||||
|
||||
GIT_WORKTREE = "git_worktree"
|
||||
COPY_ON_WRITE = "copy_on_write"
|
||||
OVERLAY = "overlay"
|
||||
TRANSACTION_ROLLBACK = "transaction_rollback"
|
||||
VERSIONING = "versioning"
|
||||
NONE = "none"
|
||||
|
||||
@property
|
||||
def supports_rollback(self) -> bool:
|
||||
"""Check if this strategy supports rolling back changes.
|
||||
|
||||
All strategies except NONE support rollback, since NONE means
|
||||
modifications are immediate and irreversible.
|
||||
"""
|
||||
return self != SandboxStrategy.NONE
|
||||
|
||||
@property
|
||||
def is_copy_based(self) -> bool:
|
||||
"""Check if this strategy creates a copy of the resource.
|
||||
|
||||
COPY_ON_WRITE and OVERLAY both duplicate the resource data
|
||||
to a separate location for isolation.
|
||||
"""
|
||||
return self in (SandboxStrategy.COPY_ON_WRITE, SandboxStrategy.OVERLAY)
|
||||
|
||||
|
||||
class Resource(BaseModel):
|
||||
"""A project resource reference.
|
||||
|
||||
Resources represent external data sources or repositories
|
||||
that a project operates on. Each resource has a type, location,
|
||||
and sandboxing strategy that determines how modifications are isolated.
|
||||
|
||||
Implements B1.2 from the implementation plan.
|
||||
"""
|
||||
|
||||
resource_id: str = Field(
|
||||
..., description="Unique ULID identifier", pattern=ULID_PATTERN
|
||||
)
|
||||
name: str = Field(
|
||||
..., min_length=1, max_length=255, description="Human-readable name"
|
||||
)
|
||||
type: ResourceType = Field(..., description="Resource classification")
|
||||
location: str = Field(..., min_length=1, description="Path or URI to the resource")
|
||||
is_remote: bool = Field(
|
||||
False, description="Whether the resource is remotely accessible"
|
||||
)
|
||||
sandbox_strategy: SandboxStrategy = Field(
|
||||
SandboxStrategy.NONE,
|
||||
description="Strategy for isolating modifications",
|
||||
)
|
||||
read_only: bool = Field(False, description="Whether the resource is read-only")
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Arbitrary key-value metadata for the resource",
|
||||
)
|
||||
created_at: datetime = Field(
|
||||
default_factory=datetime.now,
|
||||
description="Timestamp when the resource was created",
|
||||
)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls: type[Resource], v: str) -> str:
|
||||
"""Enforce naming rules: alphanumeric with hyphens/underscores, lowercased."""
|
||||
if not v.replace("-", "").replace("_", "").isalnum():
|
||||
raise ValueError("Name must be alphanumeric with hyphens/underscores only")
|
||||
return v.lower()
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
use_enum_values=False,
|
||||
frozen=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def supports_sandbox(self) -> bool:
|
||||
"""Check if this resource has a sandbox strategy other than NONE."""
|
||||
return self.sandbox_strategy != SandboxStrategy.NONE
|
||||
|
||||
@property
|
||||
def can_write(self) -> bool:
|
||||
"""Check if this resource allows write operations."""
|
||||
return not self.read_only
|
||||
|
||||
def get_sandbox_path(self, base_path: Path) -> Path:
|
||||
"""Get the sandbox directory path for this resource.
|
||||
|
||||
Args:
|
||||
base_path: The base directory for sandboxes.
|
||||
|
||||
Returns:
|
||||
Path combining base_path with the resource name.
|
||||
"""
|
||||
return base_path / self.name
|
||||
Reference in New Issue
Block a user